diff --git a/SpineViewer/Models/PreferenceModel.cs b/SpineViewer/Models/PreferenceModel.cs new file mode 100644 index 0000000..0a4b87b --- /dev/null +++ b/SpineViewer/Models/PreferenceModel.cs @@ -0,0 +1,106 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using Spine.SpineWrappers; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Threading.Tasks; +using System.Windows.Media; + +namespace SpineViewer.Models +{ + /// + /// 首选项参数模型, 用于对话框修改以及本地保存 + /// + public partial class PreferenceModel : ObservableObject + { + #region 纹理加载首选项 + + [ObservableProperty] + private bool _forcePremul; + + [ObservableProperty] + private bool _forceNearest; + + [ObservableProperty] + private bool _forceMipmap; + + #endregion + + #region 模型加载首选项 + + [ObservableProperty] + private bool _usePma; + + [ObservableProperty] + private bool _debugTexture = true; + + [ObservableProperty] + private bool _debugBounds; + + [ObservableProperty] + private bool _debugBones; + + [ObservableProperty] + private bool _debugRegions; + + [ObservableProperty] + private bool _debugMeshHulls; + + [ObservableProperty] + private bool _debugMeshes; + + [ObservableProperty] + private bool _debugBoundingBoxes; + + [ObservableProperty] + private bool _debugPaths; + + [ObservableProperty] + private bool _debugPoints; + + [ObservableProperty] + private bool _debugClippings; + + #endregion + + #region 序列化与反序列 + + /// + /// 保存 Json 文件的格式参数 + /// + private static readonly JsonSerializerOptions _jsonOptions = new() + { + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + AllowTrailingCommas = true, + ReadCommentHandling = JsonCommentHandling.Skip + }; + + /// + /// 从文件反序列对象, 可能抛出异常 + /// + public static PreferenceModel Deserialize(string path) + { + if (!File.Exists(path)) throw new FileNotFoundException("Preference file not found", path); + var json = File.ReadAllText(path, Encoding.UTF8); + var model = JsonSerializer.Deserialize(json, _jsonOptions); + return model ?? throw new JsonException($"null data in file '{path}'"); + } + + /// + /// 保存至文件, 可能抛出异常 + /// + public void Serialize(string path) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)); + var json = JsonSerializer.Serialize(this, _jsonOptions); + File.WriteAllText(path, json, Encoding.UTF8); + } + + #endregion + } +} diff --git a/SpineViewer/Resources/Strings/en-us.xaml b/SpineViewer/Resources/Strings/en-us.xaml index f46c305..48286f9 100644 --- a/SpineViewer/Resources/Strings/en-us.xaml +++ b/SpineViewer/Resources/Strings/en-us.xaml @@ -11,7 +11,8 @@ Experimental Features Open... - Preferences... + Preferences + Preferences... Exit @@ -194,4 +195,14 @@ Program Version Project URL + + Texture Loading Options + Force Premultiplied Channels + When enabled, this applies premultiplied operations to pixels during texture loading, helping to resolve black edge issues at some connections. + Force Nearest Interpolation + Force Mipmap + When enabled, this helps reduce aliasing when textures are scaled down, at the cost of slightly higher video memory usage. + + Model Loading Options + diff --git a/SpineViewer/Resources/Strings/ja-jp.xaml b/SpineViewer/Resources/Strings/ja-jp.xaml index 8eb4e4e..bbd9e7e 100644 --- a/SpineViewer/Resources/Strings/ja-jp.xaml +++ b/SpineViewer/Resources/Strings/ja-jp.xaml @@ -11,7 +11,8 @@ 実験機能 開く... - 設定... + 設定 + 設定... 終了 @@ -194,5 +195,15 @@ プログラムバージョン プロジェクトURL + + テクスチャ読み込みオプション + 強制プリマルチチャンネル + 有効にすると、テクスチャ読み込み時にピクセルにプリマルチ処理を適用し、一部の接続部分で発生する黒い縁の問題を解決します。 + Nearest補間を強制使用 + Mipmapを強制使用 + 有効にすると、テクスチャ縮小時のジャギーを軽減しますが、ビデオメモリの使用量が若干増加します。 + + モデル読み込みオプション + diff --git a/SpineViewer/Resources/Strings/zh-cn.xaml b/SpineViewer/Resources/Strings/zh-cn.xaml index 4c98840..ba76573 100644 --- a/SpineViewer/Resources/Strings/zh-cn.xaml +++ b/SpineViewer/Resources/Strings/zh-cn.xaml @@ -11,7 +11,8 @@ 实验性功能 打开... - 首选项... + 首选项 + 首选项... 退出 @@ -74,7 +75,7 @@ 插槽 清除附件 - + 动画 添加 插入 @@ -141,7 +142,7 @@ 使用自动分辨率时需要提供有效的最大分辨率 导出单个时导出时长不能为负数 必须指定 FFmpeg 导出格式 - + 画面分辨率,相关参数请在画面参数面板进行调整 导出单个 勾选后将所选模型在同一个画面上进行导出,且必须提供输出文件夹 @@ -185,7 +186,7 @@ FFmpeg 滤镜,等价于参数 “-vf” 自定义参数 FFmpeg 自定义参数,与命令行提供方式相同,例如 “-crf 23” - + 复制到剪贴板 已复制 @@ -193,5 +194,15 @@ 程序版本 项目地址 - + + + 纹理加载选项 + 强制预乘通道 + 开启后,会在加载纹理时对像素进行预乘操作,有助于解决某些情况下的连接处黑边问题 + 强制使用 Nearest 插值 + 强制使用 Mipmap + 开启后有助于改善纹理缩小时的锯齿现象,但是会略微增加显存占用 + + 模型加载选项 + \ No newline at end of file diff --git a/SpineViewer/Services/DialogService.cs b/SpineViewer/Services/DialogService.cs index 120fc89..02cb4c3 100644 --- a/SpineViewer/Services/DialogService.cs +++ b/SpineViewer/Services/DialogService.cs @@ -1,4 +1,5 @@ using Microsoft.Win32; +using SpineViewer.Models; using SpineViewer.ViewModels.Exporters; using SpineViewer.Views; using SpineViewer.Views.ExporterDialogs; @@ -51,6 +52,15 @@ namespace SpineViewer.Services return dialog.ShowDialog() ?? false; } + /// + /// 将给定的首选项参数在对话框上进行显示, 返回值表示是否确认修改 + /// + public static bool ShowPreferenceDialog(PreferenceModel m) + { + var dialog = new PreferenceDialog() { DataContext = m, Owner = App.Current.MainWindow }; + return dialog.ShowDialog() ?? false; + } + /// /// 获取用户选择的文件夹 /// diff --git a/SpineViewer/ViewModels/MainWindow/MainWindowViewModel.cs b/SpineViewer/ViewModels/MainWindow/MainWindowViewModel.cs index 80d8968..04f8b81 100644 --- a/SpineViewer/ViewModels/MainWindow/MainWindowViewModel.cs +++ b/SpineViewer/ViewModels/MainWindow/MainWindowViewModel.cs @@ -34,6 +34,7 @@ namespace SpineViewer.ViewModels.MainWindow _explorerListViewModel = new(this); _spineObjectListViewModel = new(this); _sfmlRendererViewModel = new(this); + _preferenceViewModel = new(this); } public string Title => $"SpineViewer - v{App.Version}"; @@ -56,6 +57,9 @@ namespace SpineViewer.ViewModels.MainWindow public ObservableCollectionWithLock SpineObjects => _spineObjectModels; private readonly ObservableCollectionWithLock _spineObjectModels = []; + public PreferenceViewModel PreferenceViewModel => _preferenceViewModel; + private readonly PreferenceViewModel _preferenceViewModel; + /// /// 浏览页列表 ViewModel /// diff --git a/SpineViewer/ViewModels/MainWindow/PreferenceViewModel.cs b/SpineViewer/ViewModels/MainWindow/PreferenceViewModel.cs new file mode 100644 index 0000000..e5f8529 --- /dev/null +++ b/SpineViewer/ViewModels/MainWindow/PreferenceViewModel.cs @@ -0,0 +1,191 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using NLog; +using Spine.SpineWrappers; +using SpineViewer.Models; +using SpineViewer.Services; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows.Media; + +namespace SpineViewer.ViewModels.MainWindow +{ + public class PreferenceViewModel : ObservableObject + { + /// + /// 文件保存路径 + /// + public static readonly string PreferenceFilePath = Path.Combine(Path.GetDirectoryName(Environment.ProcessPath), "preference.json"); + + private static readonly Logger _logger = LogManager.GetCurrentClassLogger(); + + private readonly MainWindowViewModel _vmMain; + + public PreferenceViewModel(MainWindowViewModel vmMain) + { + _vmMain = vmMain; + } + + /// + /// 显示首选项对话框 + /// + public RelayCommand Cmd_ShowPreferenceDialog => _cmd_ShowPreferenceDialog ??= new(ShowPreferenceDialog_Execute); + private RelayCommand? _cmd_ShowPreferenceDialog; + + private void ShowPreferenceDialog_Execute() + { + var m = Preference; + if (!DialogService.ShowPreferenceDialog(m)) + return; + + Preference = m; + SavePreference(m); + } + + private static void SavePreference(PreferenceModel m) + { + try + { + m.Serialize(PreferenceFilePath); + } + catch (Exception ex) + { + _logger.Error("Failed to save preference to {0}, {1}", PreferenceFilePath, ex.Message); + _logger.Trace(ex.ToString()); + } + } + + /// + /// 保存首选项, 保存失败会有日志提示 + /// + public void SavePreference() => SavePreference(Preference); + + /// + /// 加载首选项, 加载失败会有日志提示 + /// + public void LoadPreference() + { + if (!File.Exists(PreferenceFilePath)) return; + + try + { + var m = PreferenceModel.Deserialize(PreferenceFilePath); + Preference = m; + } + catch (Exception ex) + { + _logger.Error("Failed to load preference from {0}, {1}", PreferenceFilePath, ex.Message); + _logger.Trace(ex.ToString()); + } + } + + /// + /// 获取参数副本或者进行设置 + /// + private PreferenceModel Preference + { + get + { + return new() + { + ForcePremul = ForcePremul, + ForceNearest = ForceNearest, + ForceMipmap = ForceMipmap, + UsePma = UsePma, + DebugTexture = DebugTexture, + DebugBounds = DebugBounds, + DebugBones = DebugBones, + DebugRegions = DebugRegions, + DebugMeshHulls = DebugMeshHulls, + DebugMeshes = DebugMeshes, + DebugBoundingBoxes = DebugBoundingBoxes, + DebugPaths = DebugPaths, + DebugPoints = DebugPoints, + DebugClippings = DebugClippings + }; + } + set + { + ForcePremul = value.ForcePremul; + ForceNearest = value.ForceNearest; + ForceMipmap = value.ForceMipmap; + UsePma = value.UsePma; + DebugTexture = value.DebugTexture; + DebugBounds = value.DebugBounds; + DebugBones = value.DebugBones; + DebugRegions = value.DebugRegions; + DebugMeshHulls = value.DebugMeshHulls; + DebugMeshes = value.DebugMeshes; + DebugBoundingBoxes = value.DebugBoundingBoxes; + DebugPaths = value.DebugPaths; + DebugPoints = value.DebugPoints; + DebugClippings = value.DebugClippings; + } + } + + #region 纹理加载首选项 + + public bool ForcePremul + { + get => TextureLoader.DefaultLoader.ForcePremul; + set => SetProperty(TextureLoader.DefaultLoader.ForcePremul, value, v => TextureLoader.DefaultLoader.ForcePremul = v); + } + + public bool ForceNearest + { + get => TextureLoader.DefaultLoader.ForceNearest; + set => SetProperty(TextureLoader.DefaultLoader.ForceNearest, value, v => TextureLoader.DefaultLoader.ForceNearest = v); + } + + public bool ForceMipmap + { + get => TextureLoader.DefaultLoader.ForceMipmap; + set => SetProperty(TextureLoader.DefaultLoader.ForceMipmap, value, v => TextureLoader.DefaultLoader.ForceMipmap = v); + } + + #endregion + + // TODO: 是否自动记忆模型参数 + + #region 模型加载首选项 + + public bool UsePma { get => _usePma; set => SetProperty(ref _usePma, value); } + private bool _usePma; + + public bool DebugTexture { get => _debugTexture; set => SetProperty(ref _debugTexture, value); } + private bool _debugTexture = true; + + public bool DebugBounds { get => _debugBounds; set => SetProperty(ref _debugBounds, value); } + private bool _debugBounds; + + public bool DebugBones { get => _debugBones; set => SetProperty(ref _debugBones, value); } + private bool _debugBones; + + public bool DebugRegions { get => _debugRegions; set => SetProperty(ref _debugRegions, value); } + private bool _debugRegions; + + public bool DebugMeshHulls { get => _debugMeshHulls; set => SetProperty(ref _debugMeshHulls, value); } + private bool _debugMeshHulls; + + public bool DebugMeshes { get => _debugMeshes; set => SetProperty(ref _debugMeshes, value); } + private bool _debugMeshes; + + public bool DebugBoundingBoxes { get => _debugBoundingBoxes; set => SetProperty(ref _debugBoundingBoxes, value); } + private bool _debugBoundingBoxes; + + public bool DebugPaths { get => _debugPaths; set => SetProperty(ref _debugPaths, value); } + private bool _debugPaths; + + public bool DebugPoints { get => _debugPoints; set => SetProperty(ref _debugPoints, value); } + private bool _debugPoints; + + public bool DebugClippings { get => _debugClippings; set => SetProperty(ref _debugClippings, value); } + private bool _debugClippings; + + #endregion + } +} diff --git a/SpineViewer/ViewModels/MainWindow/SpineObjectListViewModel.cs b/SpineViewer/ViewModels/MainWindow/SpineObjectListViewModel.cs index 3904f41..c860b0c 100644 --- a/SpineViewer/ViewModels/MainWindow/SpineObjectListViewModel.cs +++ b/SpineViewer/ViewModels/MainWindow/SpineObjectListViewModel.cs @@ -121,7 +121,7 @@ namespace SpineViewer.ViewModels.MainWindow lock (_spineObjectModels.Lock) { - // XXX: 这里必须要浅拷贝一次, 不能直接对会被修改的绑定数据 args 进行 foreach 遍历 + // NOTE: 这里必须要浅拷贝一次, 不能直接对会被修改的绑定数据 args 进行 foreach 遍历 foreach (var sp in args.Cast().ToArray()) { _spineObjectModels.Remove(sp); @@ -249,6 +249,41 @@ namespace SpineViewer.ViewModels.MainWindow return true; } + /// + /// 安全地在末尾添加一个模型, 发生错误会输出日志 + /// + /// 是否添加成功 + public bool AddSpineObject(string skelPath, string? atlasPath = null) + { + try + { + // TODO: 判断是否记忆参数 + var pre = _vmMain.PreferenceViewModel; + var sp = new SpineObjectModel(skelPath, atlasPath) + { + UsePma = pre.UsePma, + DebugTexture = pre.DebugTexture, + DebugBounds = pre.DebugBounds, + DebugRegions = pre.DebugRegions, + DebugMeshHulls = pre.DebugMeshHulls, + DebugMeshes = pre.DebugMeshes, + DebugBoundingBoxes = pre.DebugBoundingBoxes, + DebugPaths = pre.DebugPaths, + DebugPoints = pre.DebugPoints, + DebugClippings = pre.DebugClippings + }; + + lock (_spineObjectModels.Lock) _spineObjectModels.Add(sp); + return true; + } + catch (Exception ex) + { + _logger.Trace(ex.ToString()); + _logger.Error("Failed to load: {0}, {1}", skelPath, ex.Message); + } + return false; + } + /// /// 从路径列表添加对象 /// @@ -289,17 +324,7 @@ namespace SpineViewer.ViewModels.MainWindow } else if (validPaths.Count > 0) { - var skelPath = validPaths[0]; - try - { - var sp = new SpineObjectModel(skelPath); - lock (_spineObjectModels.Lock) _spineObjectModels.Add(sp); - } - catch (Exception ex) - { - _logger.Trace(ex.ToString()); - _logger.Error("Failed to load: {0}, {1}", skelPath, ex.Message); - } + AddSpineObject(validPaths[0]); _logger.LogCurrentProcessMemoryUsage(); } } @@ -326,18 +351,10 @@ namespace SpineViewer.ViewModels.MainWindow var skelPath = paths[i]; reporter.ProgressText = $"[{i}/{totalCount}] {skelPath}"; - try - { - var sp = new SpineObjectModel(skelPath); - lock (_spineObjectModels.Lock) _spineObjectModels.Add(sp); + if (AddSpineObject(skelPath)) success++; - } - catch (Exception ex) - { - _logger.Trace(ex.ToString()); - _logger.Error("Failed to load: {0}, {1}", skelPath, ex.Message); + else error++; - } reporter.Done = i + 1; reporter.ProgressText = $"[{i + 1}/{totalCount}] {skelPath}"; diff --git a/SpineViewer/Views/MainWindow.xaml b/SpineViewer/Views/MainWindow.xaml index 9df47f3..d6a82c1 100644 --- a/SpineViewer/Views/MainWindow.xaml +++ b/SpineViewer/Views/MainWindow.xaml @@ -58,7 +58,7 @@ - + diff --git a/SpineViewer/Views/MainWindow.xaml.cs b/SpineViewer/Views/MainWindow.xaml.cs index e83f61d..31e92ce 100644 --- a/SpineViewer/Views/MainWindow.xaml.cs +++ b/SpineViewer/Views/MainWindow.xaml.cs @@ -61,6 +61,9 @@ public partial class MainWindow : Window vm.FlipY = true; vm.MaxFps = 30; vm.StartRender(); + + // 加载首选项 + _vm.PreferenceViewModel.LoadPreference(); } private void MainWindow_Closed(object? sender, EventArgs e) diff --git a/SpineViewer/Views/PreferenceDialog.xaml b/SpineViewer/Views/PreferenceDialog.xaml new file mode 100644 index 0000000..524db37 --- /dev/null +++ b/SpineViewer/Views/PreferenceDialog.xaml @@ -0,0 +1,132 @@ + + + + + + + +