增加精确命中检测和插槽输出

This commit is contained in:
ww-rm
2025-10-01 23:43:03 +08:00
parent 44548618e8
commit 42bd5c2830
9 changed files with 277 additions and 53 deletions

View File

@@ -1,4 +1,5 @@
using Spine.Interfaces.Attachments;
using SkiaSharp;
using Spine.Interfaces.Attachments;
using System;
using System.Collections.Generic;
using System.Linq;
@@ -56,18 +57,6 @@ namespace Spine.Interfaces
}
}
/// <summary>
/// 命中测试, 当插槽全透明或者处于禁用或者骨骼处于未激活则无法命中
/// </summary>
public static bool HitTest(this ISlot self, float x, float y)
{
if (self.A <= 0 || !self.Bone.Active || self.Disabled)
return false;
self.GetBounds(out var bx, out var by, out var bw, out var bh);
return x >= bx && x <= (bx + bw) && y >= by && y <= (by + bh);
}
/// <summary>
/// 获取当前状态包围盒
/// </summary>
@@ -112,11 +101,130 @@ namespace Spine.Interfaces
}
/// <summary>
/// 逐插槽的命中测试, 不会计算处于禁用或者骨骼未激活的插槽, 比整体包围盒稍微精确一些
/// 命中测试, 当插槽全透明或者处于禁用或者骨骼处于未激活则无法命中
/// </summary>
public static bool HitTest(this ISkeleton self, float x, float y)
/// <param name="precise">是否精确命中检测, 否则仅使用包围盒进行命中检测</param>
/// <param name="cache">调用方管理的缓存表</param>
public static bool HitTest(this ISlot self, float x, float y, bool precise = false, Dictionary<SFML.Graphics.Texture, SFML.Graphics.Image> cache = null)
{
return self.IterDrawOrder().Any(st => st.HitTest(x, y));
if (self.A <= 0 || !self.Bone.Active || self.Disabled)
return false;
if (!precise)
{
self.GetBounds(out var bx, out var by, out var bw, out var bh);
return x >= bx && x <= (bx + bw) && y >= by && y <= (by + bh);
}
else
{
float[] vertices = new float[8];
int[] triangles;
float[] uvs;
SFML.Graphics.Texture tex;
switch (self.Attachment)
{
case IRegionAttachment regionAttachment:
_ = regionAttachment.ComputeWorldVertices(self, ref vertices);
triangles = regionAttachment.Triangles;
uvs = regionAttachment.UVs;
tex = regionAttachment.RendererObject;
break;
case IMeshAttachment meshAttachment:
_ = meshAttachment.ComputeWorldVertices(self, ref vertices);
triangles = meshAttachment.Triangles;
uvs = meshAttachment.UVs;
tex = meshAttachment.RendererObject;
break;
default:
return false;
}
SFML.Graphics.Image img = null;
if (cache is not null)
{
if (!cache.TryGetValue(tex, out img))
{
img = cache[tex] = tex.CopyToImage();
}
}
else
{
img = tex.CopyToImage();
}
bool hit = false;
var trianglesLength = triangles.Length;
var texSize = img.Size;
for (int i = 0; i + 2 < trianglesLength; i += 3)
{
var idx0 = triangles[i] << 1;
var idx1 = triangles[i + 1] << 1;
var idx2 = triangles[i + 2] << 1;
float x0 = vertices[idx0] - x, y0 = vertices[idx0 + 1] - y;
float x1 = vertices[idx1] - x, y1 = vertices[idx1 + 1] - y;
float x2 = vertices[idx2] - x, y2 = vertices[idx2 + 1] - y;
float c0 = Cross(x0, y0, x1, y1);
float c1 = Cross(x1, y1, x2, y2);
float c2 = Cross(x2, y2, x0, y0);
// 判断是否全部同号 (或为 0, 点在边上)
if ((c0 >= 0 && c1 >= 0 && c2 >= 0) || (c0 <= 0 && c1 <= 0 && c2 <= 0))
{
float u0 = uvs[idx0], v0 = uvs[idx0 + 1];
float u1 = uvs[idx1], v1 = uvs[idx1 + 1];
float u2 = uvs[idx2], v2 = uvs[idx2 + 1];
float inv = 1 / (c0 + c1 + c2);
float w0 = c1 * inv;
float w1 = c2 * inv;
float w2 = c0 * inv;
float u = u0 * w0 + u1 * w1 + u2 * w2;
float v = v0 * w0 + v1 * w1 + v2 * w2;
var pixel = img.GetPixel((uint)(u * texSize.X), (uint)(v * texSize.Y));
hit = pixel.A > 0;
break;
}
}
// 无缓存需要立即释放资源
if (cache is null)
{
img.Dispose();
}
return hit;
}
}
/// <summary>
/// 逐插槽的命中测试, 命中后会提前返回结果中止计算
/// </summary>
public static bool HitTest(this ISkeleton self, float x, float y, bool precise = false)
{
var cache = new Dictionary<SFML.Graphics.Texture, SFML.Graphics.Image>();
bool hit = self.IterDrawOrder().Any(st => st.HitTest(x, y, precise, cache));
foreach (var img in cache.Values) img.Dispose();
return hit;
}
/// <summary>
/// 逐插槽的命中测试, 会完整计算所有插槽的命中情况并按顶层至底层的顺序返回命中的插槽
/// /// <param name="precise">是否精确命中检测, 否则仅使用每个插槽的包围盒进行命中检测</param>
/// </summary>
public static ISlot[] HitTestFull(this ISkeleton self, float x, float y, bool precise = false)
{
var cache = new Dictionary<SFML.Graphics.Texture, SFML.Graphics.Image>();
var hitSlots = self.IterDrawOrder().Where(st => st.HitTest(x, y, precise, cache)).Reverse().ToArray();
foreach (var img in cache.Values) img.Dispose();
return hitSlots;
}
/// <summary>
/// 向量叉积
/// </summary>
private static float Cross(float x0, float y0, float x1, float y1) => x0 * y1 - y0 * x1;
}
}

View File

@@ -88,6 +88,12 @@ namespace SpineViewer.Models
[ObservableProperty]
private bool _renderSelectedOnly;
[ObservableProperty]
private bool _usePreciseHitTest;
[ObservableProperty]
private bool _logHitSlots;
[ObservableProperty]
private bool _wallpaperView;

View File

@@ -429,11 +429,19 @@ namespace SpineViewer.Models
}
/// <summary>
/// 命中检测, 可以比整体包围盒略精确一点
/// 命中检测, 可选是否使用精确检测, 会有性能损失
/// </summary>
public bool HitTest(float x, float y)
public bool HitTest(float x, float y, bool precise = false)
{
lock (_lock) return _spineObject.Skeleton.HitTest(x, y);
lock (_lock) return _spineObject.Skeleton.HitTest(x, y, precise);
}
/// <summary>
/// 完整的命中检测, 会检测所有插槽是否命中并返回命中的插槽名称
/// </summary>
public string[] HitTestFull(float x, float y, bool precise = false)
{
lock (_lock) return _spineObject.Skeleton.HitTestFull(x, y, precise).Select(v => v.Name).ToArray();
}
public SpineObjectConfigModel ObjectConfig

View File

@@ -120,6 +120,10 @@
<s:String x:Key="Str_PlaySpeed">Playback Speed</s:String>
<s:String x:Key="Str_WallpaperView">Wallpaper View</s:String>
<s:String x:Key="Str_RenderSelectedOnly">Render Selected Only</s:String>
<s:String x:Key="Str_UsePreciseHitTest">Use Precise Hit Testing</s:String>
<s:String x:Key="Str_UsePreciseHitTestTooltip">When enabled, click detection will be performed based on pixel transparency of the model.</s:String>
<s:String x:Key="Str_LogHitSlots">Log Hit Slot Names</s:String>
<s:String x:Key="Str_LogHitSlotsTooltip">When enabled, the log box will output the model and slot information hit by each click operation (will not output when using Ctrl for multi-selection).</s:String>
<s:String x:Key="Str_ShowAxis">Show Axis</s:String>
<s:String x:Key="Str_BackgroundColor">Background Color</s:String>
<s:String x:Key="Str_BackgroundImagePath">Background Image Path</s:String>

View File

@@ -120,6 +120,10 @@
<s:String x:Key="Str_PlaySpeed">再生速度</s:String>
<s:String x:Key="Str_WallpaperView">壁紙表示</s:String>
<s:String x:Key="Str_RenderSelectedOnly">選択のみレンダリング</s:String>
<s:String x:Key="Str_UsePreciseHitTest">精密ヒットテストを使用</s:String>
<s:String x:Key="Str_UsePreciseHitTestTooltip">有効にすると、モデルのピクセル透過度に基づいてクリック判定を行います。</s:String>
<s:String x:Key="Str_LogHitSlots">ヒットしたスロット名を出力</s:String>
<s:String x:Key="Str_LogHitSlotsTooltip">有効にすると、ログボックスに各クリック操作でヒットしたモデルとスロットの情報を出力しますCtrlを押しながら複数選択する場合は出力されません。</s:String>
<s:String x:Key="Str_ShowAxis">座標軸を表示</s:String>
<s:String x:Key="Str_BackgroundColor">背景色</s:String>
<s:String x:Key="Str_BackgroundImagePath">背景画像のパス</s:String>

View File

@@ -120,6 +120,10 @@
<s:String x:Key="Str_PlaySpeed">播放速度</s:String>
<s:String x:Key="Str_WallpaperView">桌面投影</s:String>
<s:String x:Key="Str_RenderSelectedOnly">仅渲染选中</s:String>
<s:String x:Key="Str_UsePreciseHitTest">使用精确命中检测</s:String>
<s:String x:Key="Str_UsePreciseHitTestTooltip">启用后将会按像素透明度来检测点击操作是否命中了模型</s:String>
<s:String x:Key="Str_LogHitSlots">输出命中的插槽名称</s:String>
<s:String x:Key="Str_LogHitSlotsTooltip">启用后将会在日志框内输出每一次点击操作命中的模型和插槽情况(按下 Ctrl 进行多选时不会输出)</s:String>
<s:String x:Key="Str_ShowAxis">显示坐标轴</s:String>
<s:String x:Key="Str_BackgroundColor">背景颜色</s:String>
<s:String x:Key="Str_BackgroundImagePath">背景图片路径</s:String>

View File

@@ -109,6 +109,8 @@ namespace SpineViewer.ViewModels.MainWindow
AppLanguage = AppLanguage,
RenderSelectedOnly = RenderSelectedOnly,
UsePreciseHitTest = UsePreciseHitTest,
LogHitSlots = LogHitSlots,
WallpaperView = WallpaperView,
CloseToTray = CloseToTray,
AutoRun = AutoRun,
@@ -138,6 +140,8 @@ namespace SpineViewer.ViewModels.MainWindow
AppLanguage = value.AppLanguage;
RenderSelectedOnly = value.RenderSelectedOnly;
UsePreciseHitTest = value.UsePreciseHitTest;
LogHitSlots = value.LogHitSlots;
WallpaperView = value.WallpaperView;
CloseToTray = value.CloseToTray;
AutoRun = value.AutoRun;
@@ -260,6 +264,18 @@ namespace SpineViewer.ViewModels.MainWindow
set => SetProperty(_vmMain.SFMLRendererViewModel.RenderSelectedOnly, value, v => _vmMain.SFMLRendererViewModel.RenderSelectedOnly = v);
}
public bool UsePreciseHitTest
{
get => _vmMain.SFMLRendererViewModel.UsePreciseHitTest;
set => SetProperty(_vmMain.SFMLRendererViewModel.UsePreciseHitTest, value, v => _vmMain.SFMLRendererViewModel.UsePreciseHitTest = v);
}
public bool LogHitSlots
{
get => _vmMain.SFMLRendererViewModel.LogHitSlots;
set => SetProperty(_vmMain.SFMLRendererViewModel.LogHitSlots, value, v => _vmMain.SFMLRendererViewModel.LogHitSlots = v);
}
public bool WallpaperView
{
get => _vmMain.SFMLRendererViewModel.WallpaperView;

View File

@@ -240,6 +240,39 @@ namespace SpineViewer.ViewModels.MainWindow
}
private Stretch _backgroundImageMode = Stretch.Uniform;
/// <summary>
/// 仅渲染选中对象
/// </summary>
public bool RenderSelectedOnly
{
get => _renderSelectedOnly;
set => SetProperty(ref _renderSelectedOnly, value);
}
private bool _renderSelectedOnly;
/// <summary>
/// 启用精确命中测试
/// </summary>
public bool UsePreciseHitTest
{
get => _usePreciseHitTest;
set => SetProperty(ref _usePreciseHitTest, value);
}
private bool _usePreciseHitTest;
/// <summary>
/// 启用完整的命中测试并在日志中输出命中测试的插槽结果
/// </summary>
public bool LogHitSlots
{
get => _logHitSlots;
set => SetProperty(ref _logHitSlots, value);
}
private bool _logHitSlots;
/// <summary>
/// 启用桌面投影
/// </summary>
public bool WallpaperView
{
get => _wallpaperView;
@@ -247,13 +280,6 @@ namespace SpineViewer.ViewModels.MainWindow
}
private bool _wallpaperView;
public bool RenderSelectedOnly
{
get => _renderSelectedOnly;
set => SetProperty(ref _renderSelectedOnly, value);
}
private bool _renderSelectedOnly = false;
public bool IsUpdating
{
get => _isUpdating;
@@ -333,17 +359,31 @@ namespace SpineViewer.ViewModels.MainWindow
}
else if (e.Button == SFML.Window.Mouse.Button.Left && !SFML.Window.Mouse.IsButtonPressed(SFML.Window.Mouse.Button.Right))
{
var _src = _renderer.MapPixelToCoords(new(e.X, e.Y));
var src = new Point(_src.X, _src.Y);
_draggingSrc = _src;
var src = _renderer.MapPixelToCoords(new(e.X, e.Y));
_draggingSrc = src;
lock (_models.Lock)
{
// 仅渲染选中模式禁止在画面里选择对象
if (_renderSelectedOnly)
{
bool hit = false;
if (!_logHitSlots)
{
// 只在被选中的对象里判断是否有效命中
bool hit = _models.Any(m => m.IsSelected && m.GetCurrentBounds().Contains(src));
hit = _models.Any(m => m.IsSelected && m.HitTest(src.X, src.Y, _usePreciseHitTest));
}
else
{
foreach (var sp in _models.Where(m => m.IsSelected))
{
var slotNames = sp.HitTestFull(src.X, src.Y, _usePreciseHitTest);
if (slotNames.Length <= 0) continue;
hit = true;
_logger.Debug("Model Hit ({0}): [{1}]", sp.Name, string.Join(", ", slotNames));
}
}
// 如果没点到被选中的模型, 则不允许拖动
if (!hit) _draggingSrc = null;
@@ -354,10 +394,12 @@ namespace SpineViewer.ViewModels.MainWindow
{
// 没按 Ctrl 的情况下, 如果命中了已选中对象, 则就算普通命中
bool hit = false;
foreach (var sp in _models)
if (!_logHitSlots)
{
if (!sp.IsShown) continue;
if (!sp.GetCurrentBounds().Contains(src)) continue;
foreach (var sp in _models.Where(m => m.IsShown))
{
if (!sp.HitTest(src.X, src.Y, _usePreciseHitTest)) continue;
hit = true;
@@ -369,6 +411,25 @@ namespace SpineViewer.ViewModels.MainWindow
}
break;
}
}
else
{
foreach (var sp in _models.Where(m => m.IsShown))
{
var slotNames = sp.HitTestFull(src.X, src.Y, _usePreciseHitTest);
if (slotNames.Length <= 0) continue;
// 如果点到了没被选中的东西, 则清空原先选中的, 改为只选中这一次点的
// 仅判断顶层对象 (首次命中)
if (!hit && !sp.IsSelected)
{
RequestSelectionChanging?.Invoke(this, new(NotifyCollectionChangedAction.Reset));
RequestSelectionChanging?.Invoke(this, new(NotifyCollectionChangedAction.Add, sp));
}
hit = true;
_logger.Debug("Model Hit ({0}): [{1}]", sp.Name, string.Join(", ", slotNames));
}
}
// 如果点了空白的地方, 就清空选中列表
if (!hit) RequestSelectionChanging?.Invoke(this, new(NotifyCollectionChangedAction.Reset));
@@ -376,7 +437,7 @@ namespace SpineViewer.ViewModels.MainWindow
else
{
// 按下 Ctrl 的情况就执行多选, 并且点空白处也不会清空选中, 如果点击了本来就是选中的则取消选中
if (_models.FirstOrDefault(m => m.IsShown && m.GetCurrentBounds().Contains(src), null) is SpineObjectModel sp)
if (_models.FirstOrDefault(m => m.IsShown && m.HitTest(src.X, src.Y, _usePreciseHitTest), null) is SpineObjectModel sp)
{
if (sp.IsSelected)
RequestSelectionChanging?.Invoke(this, new(NotifyCollectionChangedAction.Remove, sp));

View File

@@ -215,8 +215,25 @@
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Label Content="{DynamicResource Str_RenderSelectedOnly}"/>
<ToggleButton Grid.Column="1"
IsChecked="{Binding RenderSelectedOnly}"/>
<ToggleButton Grid.Column="1" IsChecked="{Binding RenderSelectedOnly}"/>
</Grid>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" SharedSizeGroup="LabelCol"/>
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Label Content="{DynamicResource Str_UsePreciseHitTest}" ToolTip="{DynamicResource Str_UsePreciseHitTestTooltip}"/>
<ToggleButton Grid.Column="1" IsChecked="{Binding UsePreciseHitTest}" ToolTip="{DynamicResource Str_UsePreciseHitTestTooltip}"/>
</Grid>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" SharedSizeGroup="LabelCol"/>
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Label Content="{DynamicResource Str_LogHitSlots}" ToolTip="{DynamicResource Str_LogHitSlotsTooltip}"/>
<ToggleButton Grid.Column="1" IsChecked="{Binding LogHitSlots}" ToolTip="{DynamicResource Str_LogHitSlotsTooltip}"/>
</Grid>
<Grid>
@@ -225,8 +242,7 @@
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Label Content="{DynamicResource Str_WallpaperView}"/>
<ToggleButton Grid.Column="1"
IsChecked="{Binding WallpaperView}"/>
<ToggleButton Grid.Column="1" IsChecked="{Binding WallpaperView}"/>
</Grid>
<Grid>
@@ -235,8 +251,7 @@
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Label Content="{DynamicResource Str_CloseToTray}"/>
<ToggleButton Grid.Column="1"
IsChecked="{Binding CloseToTray}"/>
<ToggleButton Grid.Column="1" IsChecked="{Binding CloseToTray}"/>
</Grid>
<Grid>
@@ -245,8 +260,7 @@
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Label Content="{DynamicResource Str_AutoRun}"/>
<ToggleButton Grid.Column="1"
IsChecked="{Binding AutoRun}"/>
<ToggleButton Grid.Column="1" IsChecked="{Binding AutoRun}"/>
</Grid>
<Grid>
@@ -272,8 +286,7 @@
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Label Content="{DynamicResource Str_AssociateFileSuffix}"/>
<ToggleButton Grid.Column="1"
IsChecked="{Binding AssociateFileSuffix}"/>
<ToggleButton Grid.Column="1" IsChecked="{Binding AssociateFileSuffix}"/>
</Grid>
</StackPanel>
</GroupBox>