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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/SpineViewer/Views/PreferenceDialog.xaml.cs b/SpineViewer/Views/PreferenceDialog.xaml.cs
new file mode 100644
index 0000000..50af2d5
--- /dev/null
+++ b/SpineViewer/Views/PreferenceDialog.xaml.cs
@@ -0,0 +1,39 @@
+using SpineViewer.Services;
+using SpineViewer.ViewModels.Exporters;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Data;
+using System.Windows.Documents;
+using System.Windows.Input;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using System.Windows.Shapes;
+
+namespace SpineViewer.Views
+{
+ ///
+ /// PreferenceDialog.xaml 的交互逻辑
+ ///
+ public partial class PreferenceDialog : Window
+ {
+ public PreferenceDialog()
+ {
+ InitializeComponent();
+ }
+
+ private void ButtonOK_Click(object sender, RoutedEventArgs e)
+ {
+ DialogResult = true;
+ }
+
+ private void ButtonCancel_Click(object sender, RoutedEventArgs e)
+ {
+ DialogResult = false;
+ }
+ }
+}