Fexty12573 / SharpPluginLoader

A C# plugin loader for Monster Hunter World
MIT License
38 stars 7 forks source link

[Help Wanted] Font Loading #33

Closed GreenComfyTea closed 7 months ago

GreenComfyTea commented 8 months ago

Describe The Problem

So for correct localization support in my plugins, I need to load arbitrary fonts. Working with fonts in ImGui is pain by itself, so no wonder I am having issues.

ImGui recommends to load fonts during initialization. I tried doing so in IPlugin.OnLoad() but that throws an exception on game launch.

try
{
    var fonts = ImGui.GetIO().Fonts; // Line 53

    font = fonts.AddFontFromFileTTF(Path.Combine(Constants.PLUGIN_DATA_PATH, "NotoSansKR-Bold.otf"), 26f, null, fonts.GetGlyphRangesKorean());
    fonts.Build();

    ImGui.SetCurrentFont(font);

    var loaded = font.IsLoaded();
    Log.Info(loaded.ToString());

}
catch (Exception exception)
{
    Log.Error(exception.ToString());
}

image

Ok, then I tried to do that during runtime inside IPlugin.OnImGuiFreeRender() (once). With exact same code as above it goes without crashes but all ImGui windows stop being rendered at all.

loaded prints True thou. If I remove ImGui.SetCurrentFont(font), the result is the same. I call ImGui.PushFont(font) and ImGui.PopFont() inside IPlugin.OnImGuiFreeRender(). If I don't do that, the result is the same.

Is there something that I am missing?

PC Specs

Show/Hide - `Motherboard`**: Asus ROG Strix Z390-H Gaming** - `CPU`**: Intel Core i9-9900k** - `RAM`**: 32 GB 3200MHz** - `GPU`**: Nvidia GeForce RTX 3090 Ti** - `System and Game SSD`**: Samsung SSD 970 EVO Plus NVMe M.2 1TB** - `Plugin Repo SSD`**: Western Digital Blue SATA M.2 2280 2TB**

Environment

Show/Hide - `OS`: **Window 10 Enterprise Version 22H2 (OS Build 19045.3996)** - `Monster Hunter: World`: **v15.21.00** - `SharpPluginLoader`: **v0.0.4.1** - `Nvidia Drivers`**: v551.76**

Game Display Settings

Show/Hide - `DirectX 12 API`**: On** - `Screen Mode`**: Borderless Window** - `Resolution Settings`**: 2880x1620** (Supersampled down to 1920x1080) - `Aspect Ratio`**: Wide (16:9)** - `Nvidia DLSS`**: Off** - `FidelityFX CAS + Upscaling`**: Off** - `Frame Rate`**: No Limit** - `V-Sync`**: On**

Game Advanced Graphics Settings

Show/Hide - `Image Quality`**: High** - `Texture Quality`**: High Resolution Texture Pack** - `Ambient Occlusion`**: High** - `Volume Rendering Quality`**: Highest** - `Shadow Quality`**: High** - `Capsule AO`**: On** - `Contact Shadows`**: On** - `Anti-Aliasing`**: Off** - `LOD Bias`**: High** - `Max LOD Level`**: No Limit** - `Foliage Sway`**: On** - `Subsurface Scattering`**: On** - `Screen Space Reflection`**: On** - `Anisotropic Filtering`**: Highest** - `Water Reflection`**: On** - `Snow Quality`**: High** - `SH Diffuse Quality`**: High** - `Dynamic Range`**: 64-bit** - `Motion Blur`**: On** - `DOF (Depth of Field)`**: On** - `Vignette Effects`**: Normal** - `Z-Prepass`**: On**

Mods and External Tools:

Show/Hide - [RTSS Overlay v7.3.4](https://www.guru3d.com/page/rivatuner-rtss-homepage/) - [Reshade 6.0.0](https://reshade.me/) with SMAA.fx - [Stracker's Loader v3.0.1](https://www.nexusmods.com/monsterhunterworld/mods/1982) - [Performance Booster and Plugin Extender v1.3](https://www.nexusmods.com/monsterhunterworld/mods/3473) - [HunterPie v2.10.0.134](https://www.nexusmods.com/monsterhunterrise/mods/181) - [HelloWorld (an overlay that shows lots and lots of stuff) v10.4](https://www.nexusmods.com/monsterhunterworld/mods/142) - [Bow Spread Pattern Fix v1.0](https://www.nexusmods.com/monsterhunterworld/mods/6914) - [Tic Rate Fix v1.0](https://www.nexusmods.com/monsterhunterworld/mods/3474) - [Guiding Lands Gathering Indicator v1.12.11.01](https://www.nexusmods.com/monsterhunterworld/mods/1986) - [Gems Sorted by Name (Alphabetized Gems) v1.6.1](https://www.nexusmods.com/monsterhunterworld/mods/2364) - [Reshade Hook v1.0](https://www.nexusmods.com/monsterhunterworld/mods/7015) - [Endemic Quality (Iceborne Edition) v1.4](https://www.nexusmods.com/monsterhunterworld/mods/2137) - [Remove censor bad word banned word filter v1.0](https://www.nexusmods.com/monsterhunterworld/mods/6956) - [Monster Weakness Icon Indicator for Iceborne (Hi-Res) vFinal](https://www.nexusmods.com/monsterhunterworld/mods/1938) - [Cuter Handler Face Model (Post-Iceborne) aka Make Handler Cuter Again v3.0](https://www.nexusmods.com/monsterhunterworld/mods/1914)

Additional Context

Tea Overlay Repo Better Matchmaking Repo

Fexty12573 commented 8 months ago

OnLoad won't work because all DirectX/ImGui related stuff is only initialized during the transition to the title screen. But yeah I'll have a look, I'm not completely sure how exactly fonts work in ImGui either.

It also might take a bit before I get to it because I'm pretty busy at the moment and working on this by myself lol.

What you could try is during the first render call, load the font but with a config and enable MergeMode and specify glyph ranges. (See here)

Also keep this in mind: https://github.com/ocornut/imgui/blob/master/docs/FONTS.md#3-missing-glyph-ranges

GreenComfyTea commented 8 months ago

Yeah, no problem. Whatever time you need and remember to take breaks too!

What you could try is during the first render call, load the font but with a config and enable MergeMode and specify glyph ranges. (See here)

This doesn't work either. Same result, all ImGui windows disappear.

private ImFontPtr Font { get; set; }
private bool IsFontInitialized { get; set; } = false;

private unsafe CustomizationWindow InitFont()
{
    IsFontInitialized = true;

    var fonts = ImGui.GetIO().Fonts;

    var fontConfig = new ImFontConfig();
    fontConfig.MergeMode = 1;

    var fontConfigPtr = new ImFontConfigPtr(&fontConfig);

    // fonts.AddFontDefault(fontConfigPtr); // Uncommenting makes no difference
    Font = fonts.AddFontFromFileTTF(Path.Combine(Constants.PLUGIN_DATA_PATH, "NotoSansKR-Bold.otf"), 26f, fontConfigPtr, fonts.GetGlyphRangesKorean());
    fonts.Build();

    // ImGui.SetCurrentFont(Font); // Uncommenting makes no difference

    var loaded = Font.IsLoaded(); // True
    Log.Info(loaded.ToString());
}

public void OnImGuiFreeRender()
{
    if(!IsFontInitialized) InitFont();

    ImGui.PushFont(Font);
    ...
    ImGui.PopFont();
}
GreenComfyTea commented 7 months ago

I tried doing it inside SPL directly, in Core -> Renderer -> ImGuiRender() before ImGui.NewFrame() and I am getting same results as inside plugin's OnImguiFreeRender().

With the code below, in both cases, the windows don't disappear, thou default font is not actually updated, korean symbols don't work. Calling Build() does make them disappear.

public static unsafe void InitFont()
{
    IsFontInitialized = true;

    var io = ImGui.GetIO();
    var fonts = io.Fonts;

    ImFontConfig* config = ImGuiNative.ImFontConfig_ImFontConfig();
    config->MergeMode = 1;

    Font = fonts.AddFontFromFileTTF(Path.Combine(Constants.PLUGIN_DATA_PATH, "NotoSansKR-Bold.otf"), 26f, config, fonts.GetGlyphRangesKorean());
    // fonts.Build();
}

There was a person on ImGui.NET discord who appears to have the same issue. So it's most likely ImGui.NET bug.

image

image

Mine:

image

Fexty12573 commented 7 months ago

Have you tried loading the font inside D3DModule::imgui_load_fonts ? If it really is a bug with ImGui.NET then you could also try creating a native component and load the font from there, at least until I manage to find a proper fix for it.

GreenComfyTea commented 7 months ago

I haven't, I will take a look, thanks!

GreenComfyTea commented 7 months ago

Loading the font inside D3DModule::imgui_load_fonts worked, as I would expect. I will fiddle with a native component now.

void D3DModule::imgui_load_fonts() {
    const auto& io = *igGetIO();
    ImFontAtlas_Clear(io.Fonts);

    const auto& chunk_module = NativePluginFramework::get_module<ChunkModule>();
    const auto& default_chunk = chunk_module->request_chunk("Default");
    const auto& roboto = default_chunk->get_file("/Resources/Roboto-Medium.ttf");
    const auto& noto_sans_jp = default_chunk->get_file("/Resources/NotoSansJP-Regular.ttf");
    const auto& fa6 = default_chunk->get_file("/Resources/fa-solid-900.ttf");

    ImFontConfig* font_cfg = ImFontConfig_ImFontConfig();
    font_cfg->FontDataOwnedByAtlas = false;
    font_cfg->MergeMode = false;

    ImFontAtlas_AddFontFromMemoryTTF(io.Fonts, roboto->Contents.data(), (i32)roboto->size(), 16.0f, font_cfg, nullptr);
    font_cfg->MergeMode = true;
    ImFontAtlas_AddFontFromMemoryTTF(io.Fonts, noto_sans_jp->Contents.data(), (i32)noto_sans_jp->size(), 18.0f, font_cfg, s_japanese_glyph_ranges);
    ImFontAtlas_AddFontFromMemoryTTF(io.Fonts, fa6->Contents.data(), (i32)fa6->size(), 16.0f, font_cfg, icons_ranges);

    ImFontAtlas_AddFontFromFileTTF(io.Fonts, "D:/Programs/Steam/steamapps/common/Monster Hunter World/nativePC/plugins/CSharp/BetterMatchmaking/data/NotoSansKR-Bold.otf", 26.0f, font_cfg, ImFontAtlas_GetGlyphRangesKorean(io.Fonts));

    ImFontAtlas_Build(io.Fonts);

    ImFontConfig_destroy(font_cfg);
}
GreenComfyTea commented 7 months ago

Actually, this issue seems to be related: https://github.com/ocornut/imgui/issues/2311. Have to recreate font atlas texture (call ImGui_ImplDX11_CreateFontsTexture()/ImGui_ImplDX12_CreateFontsTexture()?).

GreenComfyTea commented 7 months ago

Okey, so. I don't know how to link ImGui to a Native Component, so I tried to modify existing SPL code. I assume I need to rebuild the texture and ImGui_ImplDX12_CreateFontsTexture() does everything I need. It calls ImFontAtlas_GetTexDataAsRGBA32 which internally calls ImFontAtlas_Build, creates font atlas texture and sets the id for it: ImFontAtlas_SetTexID.

In Managed -> Core -> Rendering -> Renderer I did this:

private static ImFontPtr Font { get; set; }

private static bool IsFontInitialized { get; set; } = false;

private static unsafe void InitFont()
{
    IsFontInitialized = true;

    var io = ImGui.GetIO();
    var fonts = io.Fonts;

    ImFontConfig* config = ImGuiNative.ImFontConfig_ImFontConfig();
    config->MergeMode = 0;
    config->FontDataOwnedByAtlas = 0;

    // Maybe I will need this, idk
    // fonts.Clear();

    fonts.AddFontDefault();
    config->MergeMode = 1;

    Font = fonts.AddFontFromFileTTF(@"D:\Programs\Steam\steamapps\common\Monster Hunter World\nativePC\plugins\CSharp\BetterMatchmaking\data\NotoSansKR-Bold.otf", 26f, config, fonts.GetGlyphRangesKorean());

    // Only doing DX12 for now for simplicity
    ImGuiExtensions.RecreateFontTextureDX12();
}

[UnmanagedCallersOnly]
internal static unsafe nint ImGuiRender()
{
    if(Input.IsPressed(_menuKey))
        _showMenu = !_showMenu;
    if(Input.IsPressed(_demoKey))
        _showDemo = !_showDemo;

    if(!IsFontInitialized) InitFont();
    ...
    ImGui.NewFrame();
    ...
}

In Managed -> Core -> Rendering -> ImGuiExtensions I added this:

public static void RecreateFontTextureDX12() => InternalCalls.RecreateFontTextureDX12();

In Native -> Header Files -> mhw-cs-plugin-loader -> Modules -> ImGuiModule.h I added this:

private:
    static void recreate_font_texture_dx12();

In Native -> Source Files -> mhw-cs-plugin-loader -> Modules -> ImGuiModule.cpp I added this:

#include "imgui_impl_dx12.h"

void ImGuiModule::initialize(CoreClr* coreclr) {
    coreclr->add_internal_call("RecreateFontTextureDX12", &ImGuiModule::recreate_font_texture_dx12);
    ...
}

void ImGuiModule::recreate_font_texture_dx12() {
    // Line that produces the error:
    ImGui_ImplDX12_CreateFontsTexture();
}

This gives me this error:

>ImGuiModule.obj : error LNK2001: unresolved external symbol "void __cdecl ImGui_ImplDX12_CreateFontsTexture(void)" (?ImGui_ImplDX12_CreateFontsTexture@@YAXXZ)
1>E:\GitHub\SharpPluginLoader\x64\Release\mhw-cs-plugin-loader.dll : fatal error LNK1120: 1 unresolved externals

Sorry, I am not very familiar with C++. I am extra allergic to declaration/implementation split and dependency hells. xd

Fexty12573 commented 7 months ago

ImGui_ImplDX12_CreateFontsTexture is declared static meaning you cannot access it from outside imgui_impl_dx12.cpp. Only if you remove the static can you do that.

Fexty12573 commented 7 months ago

Realistically however you should not be calling that yourself. You should call ImGui_ImplDX12_InvalidateDeviceObjects instead, which will lead to the font atlas being rebuilt on the next frame. Be sure to do this before the NewFrame call.

Fexty12573 commented 7 months ago

Alternatively, this PR seems to be a more generic solution to this problem. I could potentially merge this into my fork of imgui.

GreenComfyTea commented 7 months ago

If merging the PR is feasible, go for it. All I want is to allow translators to define their own font in the localization file. Ideally, I want to load fonts on request at runtime, my plugins already support auto-updating localizations at runtime. But I will be fine if it was just at initialization on startup.

eeeh

Right now I am crashing and I am not even sure where.

Screenshot ![image](https://github.com/Fexty12573/SharpPluginLoader/assets/30152047/58f6425e-3d47-488a-a89e-28ae157677e9)

In Managed -> Core -> Rendering -> Renderer:

private static ImFontPtr Font { get; set; }

private static bool IsFontInitialized { get; set; } = false;

private static unsafe void InitFont()
{
    IsFontInitialized = true;

    var io = ImGui.GetIO();
    var fonts = io.Fonts;

    ImFontConfig* config = ImGuiNative.ImFontConfig_ImFontConfig();
    config->MergeMode = 0;
    config->FontDataOwnedByAtlas = 0;

    // Maybe I will need this, idk
    // fonts.Clear();

    fonts.AddFontDefault();
    config->MergeMode = 1;

    Font = fonts.AddFontFromFileTTF(@"D:\Programs\Steam\steamapps\common\Monster Hunter World\nativePC\plugins\CSharp\BetterMatchmaking\data\NotoSansKR-Bold.otf", 26f, config, fonts.GetGlyphRangesKorean());

    fonts.Build();

    // Only doing DX12 for now for simplicity
    Log.Info("Pre-InvalidateDeviceObjectsDX12");
    ImGuiExtensions.InvalidateDeviceObjectsDX12();
    Log.Info("Post-InvalidateDeviceObjectsDX12");
}

[UnmanagedCallersOnly]
internal static unsafe nint ImGuiRender()
{
    if(Input.IsPressed(_menuKey))
        _showMenu = !_showMenu;
    if(Input.IsPressed(_demoKey))
        _showDemo = !_showDemo;

    if(!IsFontInitialized) InitFont();
    ...
    Log.Info("Pre-NewFrame");
    ImGui.NewFrame();
    Log.Info("Post-NewFrame");
    ...
    Log.Info("Pre-EndFrame");
    ImGui.EndFrame();
    Log.Info("Post-EndFrame");
    ...
    Log.Info("Pre-Render");
    ImGui.Render();
    Log.Info("Post-Render");
    ...
}

In Managed -> Core -> Rendering -> ImGuiExtensions:

public static void InvalidateDeviceObjectsDX12() => InternalCalls.InvalidateDeviceObjectsDX12();

In Managed -> Core -> InternalCalls:

public static delegate* unmanaged<void> InvalidateDeviceObjectsDX12Ptr;

public static void InvalidateDeviceObjectsDX12() => InvalidateDeviceObjectsDX12Ptr();

In Native -> Source Files -> mhw-cs-plugin-loader -> Modules -> ImGuiModule.cpp:

#include "imgui_impl_dx12.h"
#include "Log.h"

void ImGuiModule::initialize(CoreClr* coreclr) {
    coreclr->add_internal_call("InvalidateDeviceObjectsDX12", &ImGuiModule::invalidate_device_objects_dx12);
    ...
}

void ImGuiModule::invalidate_device_objects_dx12() {
    dlog::info("ImGui_ImplDX12_InvalidateDeviceObjects");
    ImGui_ImplDX12_InvalidateDeviceObjects();
}
GreenComfyTea commented 7 months ago

Ok I got it working by calling ImGui_ImplDX12_CreateDeviceObjects() instead. Much heavier function, but at least it works.

image

image

WICKED.

Merging the PR or using this is up to you. InitFont() call right before NewFrame() can be replaced with a new event, something like bool OnPreImGuiRender() that returns bool that indicates that plugins are requesting font atlas rebuild. And inside OnPreImGuiRender() plugins can initialize their fonts with just ImGui.GetIO().Fonts.AddFontFrom...().

All modified files: ImGuiModule.h: https://pastebin.com/E7Tg3L0K ImGuiModule.cpp: https://pastebin.com/yLhxJ9uP InternalCalls.cs: https://pastebin.com/ffRVfa8A ImGuiExtensions.cs: https://pastebin.com/RhDA2pXf Renderer.cs: https://pastebin.com/XmT7EEgF

Fexty12573 commented 7 months ago

I ended up choosing a different approach entirely, which is implemented in cfb9e11dfe2fcd99c9bec63139438678997eece8.

The new system lets you submit your own fonts inside OnLoad using Renderer.RegisterFont.

public static unsafe void RegisterFont(string name, string path, float size, nint glyphRanges = 0, 
    bool merge = false, int oversampleV = 0, int oversampleH = 0)

Example:

// Inside OnLoad
Renderer.RegisterFont("My Font", @"C:\Windows\Fonts\times.ttf", 16f);

// Inside OnImGuiRender
ImGui.PushFont(Renderer.GetFont("My Font"));
ImGui.Text("Sample Text");
ImGui.PopFont();

Using a more unique name than "My Font" is recommended because it is using a dictionary behind the scenes, so try to avoid name collisions with other plugins.

For specific glyph ranges do not use the GetGlyphRanges... functions provided by ImFontAtlas, use Renderer.GetGlyphRanges instead. It provides all the default glyph ranges provided by ImGui as well. If you want to use custom glyph ranges, you can use GlyphRangeFactory.CreateGlyphRanges to allocate persistent arrays that can be passed directly to RegisterFont.

GreenComfyTea commented 7 months ago

Works well so far, thank you! image

GreenComfyTea commented 7 months ago

GlyphRangeFactory is internal so it's not accessible.

image

image

Fexty12573 commented 7 months ago

Whoops, let me fix that

Edit: f8f4132fa2f85179cad726c873de6af8eb0603ce

GreenComfyTea commented 7 months ago

I am having trouble merging fonts. 7? is supposed to be 7⭐. = U+2B50. Am I missing something?

public void OnLoad()
{
    GlyphRange[] emojiRange = [(0x2122, 0x2B55), (0x0, 0x0)];

    var emojiGlyphRangeAddress = GlyphRangeFactory.CreateGlyphRanges(emojiRange);

    Renderer.RegisterFont("Default@@BetterMatchmaking", $"{Constants.PLUGIN_FONTS_PATH}NotoSans-Bold.ttf", 17f, 0, false, 2, 2);
    Renderer.RegisterFont("Default@@BetterMatchmaking", $"{Constants.PLUGIN_FONTS_PATH}NotoEmoji-Bold.ttf", 17f, emojiGlyphRangeAddress, true, 2, 2);

    GlyphRangeFactory.DestroyGlyphRanges(emojiGlyphRangeAddress);
}

image

Individually, both fonts work correctly? (Idk what the hell are those circles thou, I don't even load ASCII range?).

Just NotoSans:

public void OnLoad()
{
    Renderer.RegisterFont("Default@@BetterMatchmaking", $"{Constants.PLUGIN_FONTS_PATH}NotoSans-Bold.ttf", 17f, 0, false, 2, 2);
}

image

Just NotoEmoji:

public void OnLoad()
{
    GlyphRange[] emojiRange = [(0x2122, 0x2B55), (0x0, 0x0)];

    var emojiGlyphRangeAddress = GlyphRangeFactory.CreateGlyphRanges(emojiRange);

    Renderer.RegisterFont("Default@@BetterMatchmaking", $"{Constants.PLUGIN_FONTS_PATH}NotoEmoji-Bold.ttf", 17f, emojiGlyphRangeAddress, false, 2, 2);

    GlyphRangeFactory.DestroyGlyphRanges(emojiGlyphRangeAddress);
}

image

Fexty12573 commented 7 months ago

Not sure if this is what's causing it and it might not be communicated properly, but you shouldn't free the glyph ranges until after the font was loaded (i.e. the first time OnImGui(Free)Render was called).

In fact, I'm not sure if you should destroy them at all. If anything, do it inside OnUnload. Should make that a bit clearer. And yes, merging seems to always be weird.

Another thing, you don't need to manually add the (0, 0) entries for your glyph ranges, those are added automatically.

GreenComfyTea commented 7 months ago

Oopsies, you are right. I somehow thought that font is built on each RegisterFont, which is extremely silly of me.

Probably, I got confused because I saw the method description saying to destroy the ranges, as well as saying to provide null terminator.

GreenComfyTea commented 7 months ago

Creates a new set of glyph ranges from the provided ranges, with a null terminator at the end.

I read it as if a null terminator should belong to the provided ranges.

GreenComfyTea commented 7 months ago

It works now. Null terminator was the issue. image

Fexty12573 commented 7 months ago

I will close this issue if there are no more problems.

GreenComfyTea commented 7 months ago

Ok, sorry for bumping. Encountered an issue. Didn't test properly in the beginning.

The font initialization is called again when going back to main menu from a session, and if any plugin loads a font, it crashes.

Log file

To reproduce:

  1. Have a plugin load a custom font inside OnLoad;
  2. Launch the game (SPL loads custom fonts correctly);
  3. Go into session;
  4. Go back to main menu (SPL tries to reinitialize custom fonts?);
  5. Crash.
Fexty12573 commented 7 months ago

This should now be fixed as of 734e9f84f3c9a598db8ab3736fdc617c751d6994, haven't gotten around to fixing this until now.