一款 Windows 平台的鼠标美化工具,基于 WinUI 3 + Win2D 构建。它在屏幕上叠加一个全屏透明、可点击穿透的图层,实时渲染鼠标点击特效、悬挂绳物理、光标拖尾与光晕,并常驻系统托盘。
-
点击特效(7 种预设)
- 全局低级鼠标钩子(
WH_MOUSE_LL)检测左右中键点击,在点击位置触发动画。 sparkle闪烁粒子、confetti彩色纸屑、firework烟花绽放、shockwave冲击波、bubble上浮气泡(反向重力)、ring扩散光环、ripple水波纹。- 可调节粒子数量、喷射速度、重力、颜色。每个滑块对每个预设都有作用:光环类预设把「粒子数量」映射为圈数、「喷射速度」映射为扩散半径;这两个预设没有粒子,所以重力滑块会被置灰而不是静默失效。
- 全局低级鼠标钩子(
-
悬挂绳子 + 图标(Verlet 物理)
- 绳子顶部锚定在光标处,下方悬挂一个图标。
- 基于 Verlet 积分模拟重力、阻尼与刚度,随光标速度/加速度自然摆动。
- 自动朝向:图标沿绳子末端方向倾斜,方向取自沿绳回溯固定弧长的弦,因此高节数下也不会抖动。
- 保底机制:无论鼠标怎么移动 / 物理如何计算,悬挂物(bob)距光标永远 ≤
RopeLength - IconSize,悬挂物远端 ≤RopeLength。算法见下方「绳子物理与稳定性」。 - 图标支持内置矢量形状(五角星 / 圆形 / 方形 / 三角 / 菱形 / 心形 / 笑脸)、内置图片(🐷 粉色小猪 / 👧 二次元女孩)或自定义位图(PNG / JPG / GIF / BMP / WEBP,保留透明度)。
- GIF 动画:选择 GIF 自定义图标后自动逐帧播放,循环无卡顿。
- 渲染统一走 Win2D
DrawImage,PNG / GIF 的 Alpha 透明度被完整保留。内置图片本身也是真透明的——两张原图曾把透明网格烙进像素里,挂在光标下就是一个灰白方格块,现已抠成不规则轮廓(做法见下方「内置图片的透明背景」)。 - 5 种绳子样式:
neon霓虹线缆、glass玻璃光纤、minimal极简细线、pulse脉冲光带、beads串珠链。样式只决定形状,颜色交给配色模式(见下方「统一配色」)。 minimal是唯一扁平的样式:一条纯色带子,没有暗边、高光或外发光,接头处的结也是扁的。要边框可以自己开——RopeOutlineWidth/RopeOutlineColor只对它生效,默认为 0 即关闭。- 绳末端的小圆点(绳结)由
ShowRopeKnot控制,默认关闭。它是装饰而非补丁:绳尾几何本来就与挂件零间隙。 - 可调节悬挂总长度、节数、重力、摆动持续、刚度、粗细、配色、图标大小与颜色。设置页会实时显示总长在「绳子」与「图标」之间的分配。
- 「恢复真实物理」按钮:把挂件当作约 3 cm 的真实挂坠反推重力(9.81 m/s²),并把绳子设为不可拉伸、摆动能持续好几个来回。只改重力 / 刚度 / 摆动持续三项,长度、节数、图标、样式、配色都原样保留,按完也照样能继续手调。原理见下方「像素到米:重力不是随便选的」。
-
光标拖尾(4 种样式)
- 记录光标轨迹,按存活时间淡出,可调节配色、长度、宽度。
comet彗星拖尾(渐窄的锥形)、ribbon丝带飘带(宽度几乎不收窄,靠透明度消散)、dots光点串珠、spark电光火花(白热芯线包在宽辉光里)。- 取色参数是存活时间比例,而不是样本序号,所以指针快慢不会拉伸或压缩配色。
- 轨迹取自鼠标每一次硬件上报(125~1000 Hz),而非每帧轮询一次,因此快速画圆画弧不会退化成折线。
- 拖尾是分段整条描边画出来的,不是每个采样描一条线。后者会在每个采样点把圆头帽合成两次,在拖尾中间留下一串小圆点,尾部越细越明显(见下方「拖尾也栽在同一个圆头帽上」)。
- 平滑(0~1,默认 0.65):沿轨迹做一次滤波,把上报间隔造成的折角磨圆。上报频率跟不上画的速度时,轨迹是真的多边形——转角是真实存在的,只是手没那样拐过。光标处不受影响,所以拖尾不会和指针脱开。见下方「多边形不是拖尾的错,但得由拖尾来收拾」。
-
星云环绕(7 种环绕方式)
galaxy旋臂星系(四条旋臂的倾斜星盘)、orbit同心圆环、sphere立体星球、atom原子轨道、helix双螺旋、vortex旋涡吸入、chaos随机乱舞。sphere/atom/helix/chaos是真 3D:粒子带深度坐标,按深度排序绘制,背面自动变小变暗(透视),正面压在背面之上。vortex会回收粒子——星尘沿螺旋向外流动、到边缘淡出后从中心重新出现,淡出同时也会掐掉尾迹(见下方「星云环绕方式」)。- 星尘、云雾、彗星尾迹与星尘光晕围绕光标旋转(支持正反向)。
- 鸟群跟随(可选,默认关闭):星云不再焊死在光标上,每颗星尘各自追赶光标。甩动鼠标会把它们抛在身后,然后陆续飞回来;有硬性的最大甩开距离,越接近这个上限追回来的加速度越大。详见下方「鸟群跟随」。
- 每颗星尘为三层同心结构:中心点 → 描边(可设颜色/宽度/不透明度)→ 最外层柔和光晕。
- 星尘本体与它们的彗星尾迹走统一配色(取色参数是粒子的轨道半径,所以内圈外圈可以是不同颜色);云雾、描边、光晕是独立图层,各自保留自己的纯色。
- 颜色统一使用 RGB;云雾、尾迹、星尘本体、描边、星尘光晕分别提供明确的不透明度,描边有独立宽度、光晕有独立大小控制。
-
光标光晕(4 种样式)
soft柔和光斑(中心亮、边缘柔和衰减,带极轻微呼吸)、ring光环(中空圆环紧贴光标)、aurora极光流转(两团光斑绕光标此起彼伏)、pulse心跳脉冲(半径与亮度一起明显起伏)。- 可调节配色、半径、强度。强度只改不透明度,不会改变几何。
-
统一配色(6 种配色模式 × 8 套调色板)
- 点击特效、绳子、拖尾、星云星尘、光晕共用同一套配色引擎,所以「彩虹流光」在五处的含义完全一致。
solid单色、gradient双色渐变、rainbow彩虹流光(色相沿特效扫过并随时间流动)、palette多色流动(调色板里的几种颜色依次循环)、breathe呼吸渐变(整体在主色与辅色之间往复)、cycle循环变色(缓慢旋转主色色相,保留原本的明暗与鲜艳度)。- 8 套调色板:极光 / 霓虹 / 日落 / 深海 / 烈焰 / 糖果 / 赛博 / 草野。
- 样式与配色彻底解耦:过去「彩虹拖尾」是一种样式,于是彩虹只能配彗星形状、丝带永远无法彩虹。现在任意样式都能搭配任意配色,组合数从「6 选 1」变成 4×6。旧设置在加载时自动迁移(
rainbow拖尾 →comet样式 +rainbow配色,confetti点击 → 糖果调色板)。 - 设置页每个配色卡片顶部都有一条实时动画预览,直接显示这套参数画出来是什么样,不必靠模式名去猜。
- 不适用的控件会隐藏而不是留在那里失效:单色模式不显示辅色与速度,彩虹模式不显示颜色,只有多色流动才出现调色板。
-
系统托盘 + 开机自启
- 最小化到托盘而非退出;左键 / 双击托盘图标打开设置面板,右键弹出菜单(打开 / 退出)。
- 设置内可开启开机自启(写入注册表
Run键)。
-
关于面板
- 设置面板底部新增「关于」分组,集中展示产品名、版本号、作者、版权与仓库地址。
- 版本号旁可一键复制;仓库地址可一键在浏览器打开。
| 项目 | 说明 |
|---|---|
| UI 框架 | WinUI 3(Microsoft.WindowsAppSDK 1.6.250602001) |
| 渲染 | Win2D(Microsoft.Graphics.Win2D 1.3.2,每显示器 CanvasRenderTarget) |
| 运行时 | .NET 10(net10.0-windows10.0.19041.0) |
| 分发 | 自包含非打包(WindowsPackageType=None、WindowsAppSDKSelfContained=true) |
| 模拟 | 无 UI 依赖的 FunnyCursor.Core;120 Hz 固定步长,Verlet 绳子、粒子、拖尾与时间戳输入队列 |
| 窗口 | 每台物理显示器一个 Win32 分层窗口(WS_EX_LAYERED);同一个模拟快照经坐标投影后由各窗口渲染,支持负虚拟屏幕坐标和混合 DPI |
| 线程 | UI 线程(XAML + 托盘)/渲染线程(分层窗口 + 消息泵 + Win2D)/输入线程(WH_MOUSE_LL)三线程分离 |
| 呈现 | 脏矩形 UpdateLayeredWindowIndirect:每帧只回读并提交特效包围盒,空屏显示器整帧跳过 |
| 托盘 | Win32 消息窗口 + Shell_NotifyIcon + TrackPopupMenu |
MouseBeautifier是 WinUI 3 外壳:App.xaml提供应用资源,SettingsWindow.xaml定义设置窗口,代码隐藏负责设置绑定、文件选择和生命周期。FunnyCursor.Core不依赖 WinUI / Win2D,集中管理设置模型、固定步长时钟、时间戳输入、粒子、拖尾、绳子、挂件几何和特效包围盒,可在无桌面的测试进程中验证。OverlayHost只创建一个EffectWorld。每帧先消费鼠标输入并推进一次模拟,再把同一个只读快照投影到每台物理显示器,避免多屏分别推进导致速度翻倍或状态分叉。同一帧内的多个固定步会在上一帧与本帧光标之间插值,因此 120 Hz 的模拟拿到的是 120 Hz 的输入。- 线程模型:渲染线程自行注册窗口类、创建全部分层窗口并跑自己的消息泵,帧节拍由高精度可等待定时器驱动(
CreateWaitableTimerEx+MsgWaitForMultipleObjectsEx)。WH_MOUSE_LL钩子独占一条只跑GetMessage的输入线程,避免渲染或 XAML 忙碌时钩子回调超过系统LowLevelHooksTimeout被静默摘除。 - 指针路径:输入线程把每一次
WM_MOUSEMOVE连同时间戳入队,渲染线程按时间戳切片喂给各个固定步。等距重采样只能细分「上一帧光标 → 本帧光标」的直线弦,无法恢复曲率,所以以前画得比帧率快的曲线会明显呈折线;真实上报点补上了这段缺失的信息,绳子的锚点也一并受益。绳子的锚点和拖尾都只认录制的路径,GetCursorPos的轮询结果只在钩子从未上报过时才顶班——两个源对「现在」的判断不一致,混用会让轨迹前后来回(见「拖尾偶发的诡异弯折」)。 - 锚点只能有一个来源:有钩子数据时,帧末位置取最新的钩子采样而不是
GetCursorPos。两者混用会让锚点每帧往回跳——晚于本帧时间戳到达的采样留在队列里等下一帧,而轮询值已经包含了它们,于是第 N 帧末尾跑在第 N+1 帧开头之前。在阻尼很低的软绳上这种来回会持续激励,绳子始终不会看起来是直的。 - 星云的确定性几何位于无 UI 依赖的
NebulaLayout(与时间无关的每粒子常量在静态表中预计算一次),Win2D 分层绘制位于NebulaRenderer;布局每帧只生成一次并由所有显示器共享,设置先归一化为不可变NebulaRenderSettings(颜色已预解析为打包 RGB)。7 种环绕方式各自只实现一个「粒子此刻在哪」的采样函数,尾迹方向与长度由中心差分统一派生,详见「星云环绕方式」。 - 拖尾与光晕的样式同样下沉到 Core(
TrailStyles/GlowStyles),返回归一化的描边描述与光晕层列表;Win2D 侧只做换算与绘制,因此 8 种样式全部可在无头测试中验证,包括它们各自声明的绘制范围是否真实(脏矩形依赖这个数字)。样式只描述几何与权重,颜色由ColorRamp单独给出(见「统一配色引擎」)。 - 每台显示器拥有独立的
CanvasRenderTarget、DIB 和分层窗口,但共用同一个EffectRenderer(含图标资源、渐变画刷、描边样式与挂件几何缓存)。EffectBounds给出本帧特效在屏幕像素中的包围盒,与上一帧的包围盒求并后,只有该区域被GetPixelBytes回读、拷入 DIB 并经UpdateLayeredWindowIndirect(prcDirty)提交;包围盒与某台显示器不相交时该显示器整帧跳过。 - 分层窗口要先有内容再显示:
WS_EX_LAYERED窗口在第一次UpdateLayeredWindowIndirect之前根本没有表面,此时调用SetWindowPos(SWP_SHOWWINDOW)只会让它以「空」的状态进入合成,之后即使不断提交新内容,屏幕上依然什么都没有,直到有别的东西扰动它的 Z 序。以前正是这样:窗口一创建就显示,真正出现要等到 120 帧后那次周期性置顶刷新,开机后约两秒内特效完全不可见,用户会以为必须点一下鼠标才会触发。现在窗口创建后只定位不显示,第一帧提交成功后才SWP_SHOWWINDOW,特效在第一帧就在。 - 显示器增删、分辨率变化、
WM_DPICHANGED和 Win2D 设备丢失均会触发表面或设备资源重建。渲染线程持有自己的CanvasDevice(而非 Win2D 的共享单例),因此设备丢失时可以安全销毁重建。原生 DC、GDI 对象、鼠标钩子、菜单和图标由SafeHandle包装。 - 每种特效的配色在每帧解析一次成不可变的
ColorPlan(模式 + 主色 + 辅色 + 调色板 + 速度),渲染器拿到的是已经解包成0x00RRGGBB的整数,因此十六进制字符串解析不会出现在逐粒子的循环里。 - 设置由
ISettingsService隔离,当前实现以 schema v4 写入%LOCALAPPDATA%\FunnyCursor\settings.json;旧版扁平环绕字段(v1→v2)、旧的「样式即配色」字段(v2→v3)与旧的绳子阻尼系数(v3→v4)都会在加载时自动迁移。UI 线程改动Current后立刻原子发布一份不可变克隆(Snapshot)供渲染线程读取,实现即时预览,磁盘写入仍在停止操作 250 ms 后原子持久化。
- Windows 10 19041+ / Windows 11
- .NET 10 SDK
- Visual Studio 2022(含“使用 C++ 的桌面开发”与 Windows SDK 10.0.19041+)或仅 .NET SDK 命令行
cd MouseBeautifier
dotnet build -c Debug -p:Platform=x64cd MouseBeautifier
dotnet clean FunnyCursor.sln -c Release -p:Platform=x64
dotnet restore FunnyCursor.sln -p:Platform=x64
dotnet build FunnyCursor.sln -c Release -p:Platform=x64 --no-restore
受限环境:
dotnet build FunnyCursor.sln -c Release -p:Platform=x64 --no-restore -p:EnableSourceControlManagerQueries=false
dotnet test FunnyCursor.sln -c Release -p:Platform=x64 --no-restore --no-build首次构建会还原 WinAppSDK 自包含运行时,耗时较长,请耐心等待。
Release x64 成功构建后,主输出目录应至少包含:
FunnyCursor.exe、FunnyCursor.dll、FunnyCursor.Core.dllFunnyCursor.pri(应用与 WinUI 资源索引)App.xbf、SettingsWindow.xbf(已编译 XAML)Microsoft.WindowsAppRuntime.dll、Microsoft.ui.xaml.dll与其他自包含 Windows App SDK 文件Assets/funnycursor.ico、Assets/pig.png、Assets/girl.png
测试项目为 MouseBeautifier.Core.Tests,当前包含 224 个 xUnit 测试,覆盖:
- 固定步长在 60 / 144 Hz 呈现频率下的一致性与卡顿追帧上限。
- 时间戳输入的容量、顺序、消费门限及清空后的新时间纪元。
- 粒子生命周期和数量上限、拖尾等距重采样 / 生命周期 / 点数上限 / 压缩后仍按生成时刻有序。
- 绳子静止、瞬移、剧烈抖动、运行中几何参数变化时的有限值与长度约束。
- 绳子行为与节数解耦:18 与 120 节在急停后都必须继续摆动(速度上限曾按段长表达,保底 clamp 曾清零速度,两者叠加会让高节数的绳子瞬间「死」掉)。
RopeStiffness必须产生可见差异(它曾只切换 16~48 次求解迭代,而这两者早已收敛)。- 帧内光标插值:60 Hz 与 120 Hz 呈现下同一段物理运动必须落在同一位置。
- 拖尾必须沿真实指针路径而非帧间弦:单帧内录入半圈圆弧后,所有样本到圆心的距离误差 < 5 px(走弦的话误差就是整个半径)。
- 拖尾只听录制、不听轮询:按「上报比出帧慢」(50 Hz 上报 / 60 Hz 出帧 + 一帧 100 ms 卡顿)扫一段圆弧,样本既不许偏离圆弧超过 2 px,也不许在角度上倒退——弯折主要是「冲前面去了又被拽回来」,那种错位不改变半径。
TrailPath平滑:首尾点分毫不动、直线不被弯、位移不超过脏矩形留的余量、结果落在原路径的凸包内(否则脏矩形会漏)、上报间距造成的最尖转角要降到理想采样值以下,同时 150 px 的圆内缩不超过 1.5 px。RopePath描边折线:末段与挂件轴严格平行、首尾点不移动、直绳不被改动、形变只发生在绳尾。- 静止姿态:遍历长度 / 节数 / 重力 / 刚度 / 摆动持续 / 图标大小的边界组合(972 种,重力扫到新上限 40000),验证链条竖直、不折回、挂件倾斜 < 0.5°。
- 扰动后收敛:绳子被猛甩后,摆动包络必须逐秒单调衰减(上升即意味着有东西在给系统注入能量),并在各摆动持续档的时限内落到 1px 以内;时限不是拍脑袋定的,而是由半衰期推出来的(一秒甩动 + 30px 摆幅衰减到 1px 所需的五个半衰期)。静止收敛后链长必须等于其跨距(多余长度只会变成侧弯)。
- 摆动持续必须每四分之一行程都有变化:来回摆的次数在 0 / 0.25 / 0.5 / 0.75 / 1 上必须严格递增,最低档必须真的摆不动,默认档必须至少完成两次完整往复。这条是拿来守住「滑块不许有死区」的——旧版本下五分之四的行程都是同一个「摆不动」。
- 绳子只拉不推:光标笔直落向挂件 99px,挂件被带下的距离必须小于 75%(对称距离约束会把它当硬杆顶下去 92px)。
- 真实物理配置:反推出的重力必须让摆动周期精确等于
2π√(L/g)的实数解(四种长度 × 图标组合);按钮只改重力 / 刚度 / 摆动持续,长度、节数、粗细、图标、样式、配色、绳结开关一律不动;出厂默认必须已经等于这套反推结果,否则"默认"和"恢复"会是两回事;三种图标大小(重力 3270~39240,跨两个数量级)下猛甩 300 帧不出非有限值,静置后偏离竖直 < 0.5px。 - 加密收敛:18 / 40 / 80 / 120 节的平均滞后向量必须逐级靠拢(每级差距缩到上一级的六成以内),且 80 与 120 节相差 < 2px。粗细两种离散化的瞬时位置不是不变量,所以比较的是整段甩动的平均值。
- 绳子阻尼迁移:v3 的速度乘数按滑块位置落到 v4 的 0~1 手感控制上,两端仍是两端、旧默认落在新默认上;已经是 v4 的文件不被二次改写;没写这一项的文件保持默认。
- 传感器抖动:模拟鼠标静置时 ±1px 的上报抖动、且部分采样晚于本帧时间戳,绳子偏离竖直必须 < 4px。
- 点击预设契约:每个预设都画得出东西、都响应「粒子数量」滑块、重力滑块「恰好在预设声明使用它时」才生效、气泡上浮而闪光下落、光环扩散速度不再随半径线性放大。
- 挂件连接点、方向、非法输入回退和
R * T变换顺序。 - 特效包围盒:全关时为空、光晕覆盖光标邻域、挂件顶点始终在盒内、按显示器裁剪与负坐标映射、不相交显示器返回「无需重绘」。
- 负坐标、多显示器混合 DPI 往返映射及单一共享模拟快照。
- 星云参数范围、RGB/不透明度契约、旧 JSON 迁移、布局确定性、零速尾迹与反向旋转。
- 环绕方式契约(7 种全覆盖):有限值 / 确定性 / 单位切向 / 不超出配置范围;停转即无尾迹;反向旋转产生精确取反的切向与完全相等的尾迹长度;尾迹长度随轨道速度线性放大;7 种方式互不相同;3D 方式中「深度变远的粒子必然同时变小」,而平面方式的粒子尺寸不随旋转改变;球面必须真的填满两个半球。
- 鸟群跟随契约:任意乱走与瞬移下都不越过最大甩开距离且保持有限;甩动确实会把星尘抛在身后、停下之后必定全部归位(< 0.05 px);距离越远追回的比例越大(不只是绝对速度更大);星尘不会齐步走;两个滑块的四个极端组合都既不发散也不长鸣;停稳后的布局与关闭该开关时逐位一致——这条是「只滞后锚点、不滞后轨道」的守门测试;空锚点数组必须给出与老实现完全相同的结果;密度调大时新粒子从光标起步;滞后的星尘与云雾中心都必须在脏矩形内。
- 尾迹不得失控:以 1/3 转速扫过多圈(含角度折叠边界),任何粒子的尾迹长度都必须远小于星云半径——回收类方式的接缝会被中心差分读成极高速度,既显示错误也会把脏矩形撑到全屏。
- 拖尾样式契约(4 种全覆盖,纯几何):头部一定可见、尾部一定淡到全透明、alpha 与宽度不越界;
comet单调收窄;ribbon在尾部仍比comet宽 4 倍以上;spark芯线更白且辉光远宽于芯线;已退役的gradient/rainbow名字回退到comet而不是抛异常。 - 拖尾的透明度必须走凹曲线:走到一半时仍要保留头部 65% 以上的不透明度(线性衰减只会剩 50%),且宽度不许跟着弯(否则彗星的尾巴会变胖成丝带)。这条守的是「渲染层不再重复合成之后,浓度是显式设计出来的,不是又一次意外」。
- 光晕样式契约(4 种全覆盖,纯几何):层数、可见度与有限值;强度线性缩放透明度且不改动几何;NaN / 无穷输入被吸收;只有
ring会描边;soft仍严格等于样式化之前的两层参数;aurora两瓣的取色轴严格对称;pulse半径摆幅 > 1.4 倍;缓冲区过小会抛异常。 - 配色引擎契约(6 种模式 × 8 套调色板全覆盖):任意位置与时间下都停留在 24 位内且完全确定;越界与 NaN 输入被夹住而不是传播;
solid无视位置/时间/速度;gradient头尾精确落在主色与辅色上;速度为 0 只冻结时间不破坏空间渐变;随时间变化的模式都从主色起步;均匀模式在同一时刻处处同色而非均匀模式必须沿特效变化;rainbow扫遍六个色相区间且完全饱和;每套调色板的每种颜色都取得到、跨越末尾时无缝回到首色;cycle保留主色的明暗与鲜艳度、灰色不动;UsesPrimary/UsesAccent/UsesPalette/UsesSpeed的声明必须与Resolve真实读取的字段一致(设置页靠它决定显示哪些控件,说谎就会藏掉有用的控件或留下无效控件)。 - 配色迁移:v2 的
rainbow/gradient拖尾样式拆成「几何样式 + 配色模式」、rainbow绳子落到neon、confetti点击落到糖果调色板;已经显式写了配色模式的文件不被二次改写;当前版本的文件完全不迁移。 - 样式声明的绘制范围必须真实:脏矩形是按
TrailStyles.MaximumHalfWidth/GlowStyles.MaximumExtent计算的,所以测试会遍历新鲜度 / 位置 / 时间,验证实际返回的描边宽度与光晕触及范围永不超过声明值——少声明一点,屏幕上就会被裁掉一圈辉光。 - 2,000 帧高复杂度无头模拟的宽松耗时与托管分配预算(≤ 64 KB),用于捕获每帧闭包或字符串分配等明显性能回归;它不是硬件渲染基准。
构建产物位于 MouseBeautifier/bin/x64/<Debug|Release>/net10.0-windows10.0.19041.0/。
直接运行 FunnyCursor.exe 即可。设置文件保存在:
%LOCALAPPDATA%\FunnyCursor\settings.json
- 启动后托盘区出现图标,屏幕上即开始显示美化效果。
- 左键双击托盘图标(或右键 → 打开面板)打开设置窗口。
- 在设置窗口中实时调整各项参数,修改即时生效。
- 关闭设置窗口会最小化到托盘;从托盘菜单选择“退出”才真正结束程序。
- 全局快捷键
Ctrl+Shift+Q可随时退出程序(即使设置面板未打开)。- 若该快捷键被其他程序占用,会自动回退到
Ctrl+Alt+Q。
- 若该快捷键被其他程序占用,会自动回退到
| 分组 | 设置 | 说明 |
|---|---|---|
| 点击特效 | 启用 / 预设(7 种)/ 配色 / 粒子数 / 速度 / 重力 | 控制点击动画;光环类预设把粒子数当圈数、速度当半径,且重力滑块置灰。配色的取色参数是每颗粒子的随机种子(光环则是扩散进度) |
| 绳子 | 启用 / 悬挂总长度 / 节数 / 重力 / 摆动持续 / 刚度 / 样式(5 种)/ 配色 / 粗细 / 图标类型(含内置图片选项) / 大小 / 颜色 / 自定义路径 | 控制悬挂物理。配色是沿绳连续的渐变,不随节数分段。「摆动持续」是 0 |
| 拖尾 | 启用 / 样式(4 种)/ 配色 / 长度 / 宽度 / 平滑 | 控制光标轨迹。宽度只影响描边粗细,不再牵动采样间距(间距固定 3 px)。配色沿轨迹由头到尾取色。平滑按弧长滤波,磨掉上报间隔造成的折角 |
| 星云环绕 | 启用 / 环绕方式(7 种)/ 数量 / 范围 / 速度 / 星尘大小 / 星尘配色 / 云雾颜色 / 描边颜色+宽度 / 光晕颜色+大小 / 各层不透明度 | 中心点→描边→光晕 三层结构;云雾、描边、光晕拥有独立颜色,可与星尘不同。「环绕方式」中有 4 种是真 3D,背面自动变小变暗。星尘配色按轨道半径取色 |
| 光晕 | 启用 / 样式(4 种)/ 配色 / 半径 / 强度 | 控制光标光晕。强度只改不透明度,不改几何。配色按光晕层由内到外取色 |
| 常规 | 开机自启 | 写入注册表自启 |
上表的「配色」都是同一个控件:配色模式 + 主色 + 辅色 + 调色板 + 流动速度,顶部带实时动画预览,用不到的字段自动隐藏。
点击"恢复默认设置"可一键还原;图标类型除矢量形状外,还提供 粉色小猪 与 二次元女孩 两款 AI 生成内置图片图标,无需额外文件;选择"自定义图片"需点击"浏览…"选择本地 PNG / JPG / GIF / BMP / WEBP(支持透明背景,GIF 自动播放动画)。
MouseBeautifier/
├── App.xaml / App.xaml.cs # WinUI 应用资源、单实例与生命周期
├── SettingsWindow.xaml / .xaml.cs # Mica 设置窗口及代码隐藏
├── ColorModeEditor.xaml / .xaml.cs # 复用的配色控件(动画预览 + 按模式增删控件)
├── OverlayHost.cs # 渲染线程、每显示器分层窗口、脏矩形提交与设备恢复
├── EffectRenderer.cs / NebulaRenderer.cs # Win2D 视觉投影与星云分层绘制
├── RenderResources.cs # 跨显示器共享的画刷/描边/几何/光晕精灵缓存
├── IconResourceManager.cs / IconImage.cs # PNG/GIF 等位图图标资源
├── MouseTracker.cs / TrayIcon.cs # 独立输入线程的全局钩子、托盘与退出快捷键
├── JsonSettingsService.cs # JSON 设置、原子快照发布与开机启动注册
├── NativeMethods.cs / NativeHandles.cs # P/Invoke 与原生资源安全句柄
├── MouseBeautifier.Core/
│ ├── EffectWorld.cs # 单一模拟世界与共享帧快照
│ ├── EffectBounds.cs # 纯几何特效包围盒(脏矩形来源)
│ ├── NebulaSettings.cs / NebulaLayout.cs # 星云参数契约、不可变快照与 7 种环绕方式纯几何
│ ├── NebulaSwarm.cs # 鸟群跟随:每粒子锚点弹簧与硬性甩开上限
│ ├── ColorRamp.cs # 统一配色引擎:6 种模式、8 套调色板与色彩数学
│ ├── TrailStyles.cs / GlowStyles.cs # 拖尾与光晕的几何、权重及声明的绘制范围
│ ├── TrailPath.cs # 拖尾绘制前的按弧长滤波(磨掉上报间隔的折角)
│ ├── EffectStyles.cs # 绳子样式与点击预设的名称归一化
│ ├── AppSettingsJson.cs # schema v4 序列化与旧设置迁移
│ ├── FixedStepClock.cs # 120 Hz 固定步长
│ ├── TimestampedInputQueue.cs # 有界线程安全输入
│ ├── RopeSimulator.cs # Verlet 绳子物理
│ ├── RopePath.cs # 描边折线(绳尾贴合挂件轴,消除接缝折角)
│ └── DisplayGeometry.cs / PendantGeometry.cs
├── MouseBeautifier.Core.Tests/ # xUnit 无头回归与性能预算测试
├── Assets/ # 内置图标与挂件图片
├── app.manifest
├── MouseBeautifier.csproj
└── FunnyCursor.sln
- 仅支持 x64 与 Windows 平台。
- 透明覆盖层依赖 DWM、Win2D 和显卡驱动;远程桌面、HDR、独占全屏、显卡驱动重置等环境下的视觉与置顶行为可能不同。
- 分层窗口仍需把 GPU 渲染结果读回 CPU 才能提交。脏矩形把这份成本压到特效包围盒大小,但特效范围本身很大时(大半径星云 + 长拖尾)代价依旧随分辨率上升,60 FPS 是目标而非实时保证。
- 混合 DPI 坐标数学已有无头测试,但显示器热插拔、主屏切换、旋转、不同缩放比例与负坐标布局仍需在真实硬件上验证。
- 自动化测试不创建 WinUI 窗口,也不验证托盘、全局鼠标钩子、点击穿透、Mica、颜色 / 文件选择器、GIF 实际播放、脏矩形提交或设备丢失后的视觉恢复。
- 自定义图标只支持位图(PNG / JPG / GIF / BMP / WEBP),经 Win2D
CanvasBitmap解码;不支持 SVG。 - 星云光晕和云雾的“不透明度”是径向渐变中心的峰值,边缘仍会按发光效果自然衰减到透明。
- 星云光晕以预渲染精灵缩放绘制,极小半径下的边缘平滑度略低于逐粒子求值的径向渐变。
7 种环绕方式共用一套采样契约:每种方式只负责回答「这颗粒子此刻在哪」,其余全部派生。
彗星尾迹要沿运动方向。7 种方式各写一份解析切向意味着 7 处可以各自写错,而且写错了只表现为「尾迹方向有点怪」,很难注意到。现在改为只推进旋转角、保持时间不变,前后各取半步采样再相减,速度与方向都从位置公式本身派生:
- 新增方式无需再提供切向,写不出不一致的尾迹。
- 呼吸、抖动、摆动都只与时间有关,在差分中精确抵消,不会把有机运动误当成轨道速度涂进尾迹。
- 之所以取「中心」差分而非前向:反转旋转方向时,中心差分给出逐位取反的速度向量与完全相等的模长,因此「反向旋转 = 尾迹精确翻转」是恒等式而不是近似(前向差分会差一个二阶项)。
- 转速为 0 时前后两次采样完全相同,尾迹长度恰好为 0,不需要额外特判。
vortex 会把流到边缘的粒子送回中心,这个跳跃被中心差分读成极高速度,实测能画出 2000+ px 的假尾迹。它不只是难看:脏矩形是按尾迹长度算的,一条横贯屏幕的假尾迹会让每帧都退化成全屏回读。
处理方式是让淡出同时门控尾迹长度——本来就要被淡掉的粒子不该留下拖影,语义正确且不需要魔法阈值。为此把淡出因子移到亮度下限之后相乘(下限是给 3D 背面留可读度的,不能顺带阻止回收类方式真正淡到 0)。另外在 NebulaLayout 保留一道「尾迹不超过星云半径」的结构性兜底:满速轨道的合法尾迹峰值约 0.94 倍半径,所以兜底不会碰到正常值,但任何将来带接缝的新方式都不会再把脏矩形撑到全屏。
Spin 用 IEEERemainder 把无界的累计角折进一圈,避免单精度在长时间运行后变粗。代价是这个折叠本身是一次 360° 跳变——其余方式只把它送进 sin/cos,周期性让跳变完全不可见,但 vortex 还用它驱动半径,于是每次折叠整条螺旋都会跳一下。因此 vortex 的推进速率被约束为每转整数圈(VortexPassesPerTurn),让折叠正好落在螺旋的周期边界上。
直觉做法是对最终位置做低通滤波。这是错的:粒子的最终位置里已经包含了轨道运动,滤掉高频等于把轨道本身也一起阻尼掉——鼠标一动,星云就会缩小半径、转速变慢,停下才慢慢恢复。
所以滞后的是锚点:每颗星尘绕着自己的锚点走完全精确的轨道,只有锚点在追光标。轨道几何一个字都不用改,NebulaLayout.Generate 只是多收一段锚点数组;数组为空就等于「焊死在光标上」,也就是关掉这个开关时逐位相同的老行为。
为什么是每颗一个锚点,而不是一个共享锚点。 共享锚点只会让整张图平移,看起来像在拖一张贴图。每颗星尘有自己的弹簧速率(Agility,0.62~1.38 的确定性哈希),快的先到、慢的殿后,这个先后差才是「鸟群」的观感来源。
「离得越远赶得越快」需要额外一项。 线性弹簧在固定时间内追回的是同样的比例,所以绝对速度确实随距离上升,但相对进度不变——甩得远的看起来反而更久回不来。因此速率里乘上 1 + 1.6 × (距离 / 上限):越靠近上限,弹簧越硬,落在最后的那批加速度最大,队形会自己收拢而不是越拉越散。
阻尼是指数衰减而不是 -2ζωv。 教科书那一项在 速率 × 步长 超过 1 左右时会把速度反号,而「追赶速度」滑块推到最大时确实能到这个区间。指数衰减无条件稳定。另外 速率 × 步长 有一道 1.2 的硬上限:半隐式欧拉在 2 附近发散,这道上限保证以后改常数或改步长都不会把星尘甩出屏幕,最坏情况只是追得慢一点。
上限是硬夹的,但夹的时候只扣掉逃逸分量。 把整个速度清零会让贴着上限跑的粒子看起来撞在一堵墙上;只减掉朝外的那部分,它就是被绳子拖着走。
云雾不是逐粒子画的,而是三个解析椭圆,所以它需要自己的中心——取所有锚点的质心。用质心而不是再跑一根弹簧,是为了让它在定义上不可能和它本该笼罩的星尘分家。EffectBounds 的云雾包围盒也跟着改到这个中心,否则脏矩形会把飘在后面的那团雾裁掉一截。
彗星尾迹仍然只由轨道速度决定,不掺锚点速度。追赶时星尘不会拖出朝向运动方向的条纹——这是有意保守:尾迹长度是脏矩形的输入之一,多一个速度来源就多一条需要单独兜底的失控路径。
sphere / atom / helix / chaos 携带归一化到 −1..1 的深度:既作为画家排序键,也映射成 0.45~1 的尺寸与亮度缩放,背面因此自动变小变暗。sphere 用黄金角(Fibonacci 球)分布,避免退化成一叠纬度带。平面方式的深度缩放恒为 1,所以 galaxy 的观感与引入这套机制之前逐位相同。
过去「彩虹」是一种拖尾样式,也是一种绳子样式。这个划分从一开始就是错的:彩虹讲的是颜色怎么取,彗星讲的是线条怎么收窄,两者本来正交。把它们塞进同一个枚举的代价是组合被锁死——想要彩虹的丝带、想要渐变的串珠,都做不到,而且同一个「彩虹」在拖尾和绳子里是两份独立实现,可以各自跑偏。
现在 TrailStyles / GlowStyles / RopeStyles 只回答「这一点画多宽、多亮」,ColorRamp.Resolve(plan, position, time) 只回答「这一点是什么颜色」。五种特效喂给 position 的东西各不相同——拖尾用年龄占寿命的比例,绳子用离光标的距离,星云用粒子的轨道半径,点击粒子用每颗粒子的随机种子,光晕用层序号——但约定统一:0 是最近 / 最新 / 最内,1 是最远 / 最旧 / 最外。于是加一种新样式不必再想颜色,加一种新配色也自动对所有特效生效。
脏矩形按 TrailStyles.MaximumHalfWidth / GlowStyles.MaximumExtent 计算。少声明一点不会崩,只会让屏幕上被裁掉一圈辉光——这类缺陷肉眼很难定位到成因。所以两个函数的返回值都有测试遍历新鲜度 / 位置 / 时间去逼,确保实际返回的宽度与触及范围永不超过声明值。这也是为什么 spark(辉光宽达 4.2 倍宽度)与 pulse(呼吸到 1.14 倍半径)必须显式声明自己超出了默认范围。
拖尾的取色参数是年龄占寿命的比例,不是样本序号。按序号取色会让指针快慢改变配色的空间分布(快速划动时样本多,渐变被拉长);按时间取色则保证「头部永远是主色、离头 0.3 秒处永远是同一个颜色」。
绳子那条教训在拖尾里原样重演过一次,而且更明显。拖尾原本每个采样描一条线——听起来是最精确的做法,每一小段都拿到属于自己年龄的颜色、宽度和透明度。但每条线两端都是圆头帽,相邻两段共用一个端点,那颗直径等于线宽的圆盘就被合成了两次:15% 的 alpha 叠两次变成 28%。于是拖尾正中间挂着一串比线本身更实的小圆点。
最难看的地方恰好在最细处。 采样间距固定 3px,而彗星的锥形收窄到尾部只剩 0.5~1px 宽。一旦线宽掉到间距以下,那些加倍的圆盘就彼此分开了——不再是"一条略有涟漪的线",而是真的一颗一颗的点。用户看到的"很多小圆点"就是它。
修法和绳子同源但不同解:拖尾的颜色和宽度都沿路径变,一支渐变画刷解决不了宽度。所以改成把折线切成 64 段,每段是一个几何、描一次——单次描边由 Direct2D 自己解决内部转角,不管怎么自我重叠都只合成一次。段与段之间改用平头帽对接而不是圆头帽叠放,于是接缝处一次重复合成都没有。代价是转弯处会在外侧留一个楔形缺口,大小正比于那一个顶点的转角;3px 采样下这是零点几个像素,而且缺口读起来是"什么都没有",圆点读起来是"多了个东西"——后者刺眼得多。真要出现大转角(急停回甩),那里的拖尾本来就自我重叠,缺口会被另一半盖住。
顺带把丢掉的浓度补了回来。旧的双重合成等价于把每一片的 alpha 与自己做一次 screen(a → 2a − a²),去掉之后中段会明显变淡、拖尾看着短了两成。这条曲线本身是好的——alpha 线性衰减读起来消失得太早,因为合成是乘性的,走到一半时的半透明其实已经接近看不见。所以现在把它写成显式的 Fade(f) = f(2−f) 放进 TrailStyles,只作用于透明度、不作用于宽度(宽度也弯了的话尾巴会变胖,彗星就成了丝带)。数值上与旧观感差在 1% 以内,但这次是故意的,而且有测试盯着它不许退回线性。
用户画的圆上偶尔会出现一处横向的错位——线走到某处忽然向外顶出一小段,又折回来接着走。手不可能画出那样的轨迹,而且它只是偶尔出现,同一个圆上别处都好。
根子在拖尾同时听了两个消息源。固定步在消费属于自己那一片的真实上报点之后,还会把帧间插值出来的光标也喂进去一次。而轮询和录制对「现在」的理解不一样:
- 钩子跑在独立的输入线程上,事件的时间戳是回调真正执行的那一刻打的,不是硬件产生的那一刻。
- 每帧先取一个时间戳
T,只取走≤ T的上报点,然后再GetCursorPos()轮询一次。 - 输入线程只要瞬时落后一点(负载、调度),好几次硬件移动就已经更新了
GetCursorPos的结果,但它们的回调还没跑,时间戳注定> T,于是这一帧一个上报点都取不到。
结果是这一帧把拖尾推到了轮询位置,跑到真实路径前头去了;下一帧那些迟到的上报点终于进来,位置却是更早的,于是拖尾又被拽回去。前进—回退—再前进,就是那处弯折。绳子早就立了「锚点只能有一个来源」的规矩,但拖尾没跟上;绳子被物理糊平了看不出来,拖尾是一份记录,写进去就一直留在屏幕上。
修法是让这条规矩对拖尾也生效:钩子一旦证明它在工作,录制的路径就是拖尾唯一的来源,轮询只在从来没收到过任何上报点时(钩子装不上)才顶班。测试按「上报比出帧还慢」构造这个场景(50 Hz 上报、60 Hz 出帧,中间夹一帧 100 ms 的卡顿),改之前拖尾偏离圆弧 26.7 px,改之后 1.1 px——正好是 50 Hz 上报在这个半径上的弦高,也就是数据本身的分辨率极限。
值得说清代价:拖尾的头部现在最多落后输入线程一个上报周期,而光晕画在轮询位置上。正常情况下两者是同一个事件,差值为零;只有输入线程落后时才会分开,而那时候本来就已经全线延迟了。
上一条修完之后,剩下的「明显多边形」是真实数据的样子,不是 bug。拖尾按 3px 等距重采样存路径,但重采样只能沿两次上报之间的直线弦细分——它没有曲率信息可以还原。所以画得比上报快时,存下来的是一串精确的 3px 采样点,串成的却是一个 20~40px 边长的多边形。
眼睛认出多边形靠的是转角,不是弦高。150px 半径上 24px 的弦,弦高只有 0.5px,可宽描边下那个曲率突变依然一眼就能看到。所以 TrailPath 在绘制时做一次滤波,指标也定在转角上:同一段路径,滤波前最尖的顶点转 9.2°,滤波后 0.8°,而这个圆理想采样下每个顶点本来就要转 1.15°——也就是说已经比"理想的折线"更平滑了。
几个刻意的选择:
- 在绘制时滤波,不改存下来的路径。 存的是指针真实走过的地方;而且随着新采样从后面涌进来,同一个点身上的滤波窗口还在变宽,把结果写回历史就会自相矛盾。
- 三角核,不是样条。 采样点已经沿路径等距,所以定长的抽头天然就是「按弧长」滤波,指针快慢不影响滤波尺度。三角核等于两遍方框平均,一趟算完,不用暂存。
- 核在两端收缩到 0。 最新那个点是活着的光标,挪它一个像素拖尾就会看着和指针脱开。
- 位移带硬上限(8px)。 脏矩形要能不遍历平滑后的路径就说出一个边界。手画的曲线离这个上限很远(半圆上位移约
126/Rpx,50px 的圈也才 2.5px),上限只在比 20px 还小的圈上才生效。 - 代价是曲线会略微内缩:150px 的圆缩 1.2px,不到 1%,比拖尾能设的最细描边还小。
平滑是 0~1 的滑块而不是开关,默认 0.65(±24px)。默认开着,因为它治的不是拖尾的缺陷,而是所有拖尾都逃不掉的上报频率限制。
最直接的做法是把绳子按节切开、每段描一种颜色,但那样每个接缝都看得见:描边是圆头帽(round cap),相邻两段共用一个端点,于是那个圆帽被合成了两次,透明度叠加成一颗亮点。结果是配色本该掩盖的节点结构,反而被配色本身一颗一颗标了出来。颜色的阶梯跳变还是次要的,接缝才是主因。
所以绳子的每一层都必须是一次描边,而能让单次描边呈现多种颜色的只有渐变画刷。ColorPlan.IsUniform 仍是分支依据:solid / breathe / cycle 每帧解析一次颜色走纯色描边;gradient / rainbow / palette 则把色带采样成 32 个 stop,做成一支沿「光标 → 挂件」直线方向的 CanvasLinearGradientBrush。
选直线弦作为渐变轴,是因为线性渐变只能沿直线走。每个 stop 被放在它对应的绳上点投影到这条弦的位置,所以近乎笔直的悬垂姿态(绳子绝大多数时间的样子)映射是精确的。绳子对折回来时投影会非单调,此时把 offset 强制单调递增,渐变在折叠段停住而不是倒着走;弦长退化到接近 0 时直接改用色带中点的纯色。
顺带一提,这比原来的分段快得多:分段方案在 40 节的绳子上每帧要建 60 多个折线几何、发近百次绘制,现在是 2 个几何、3~4 支画刷、4 次绘制。
每种绳子样式都是若干层描边的叠加(外发光 → 描边暗边 → 本体 → 高光),每层把基色朝深色或白色推一段距离。纯色模式下这就是一次 Blend;渐变模式下则要把同样的 Blend 施加到 32 个 stop 上,再生成这一层自己的画刷。因此样式只需声明「往哪个颜色推多少、多宽、多透明」(RopeShade),至于拿到的是一种颜色还是一整条色带,由绘制层决定。串珠样式的每颗珠子也共用同一支画刷——渐变是空间画刷,每颗珠子自动取到它所在位置该有的颜色。
上面这套叠层正是立体感的来源:暗边勾出轮廓,高光暗示光从某个方向打过来,绳子于是读起来像一根有粗细的圆柱。minimal 原本也照做了——暗边加细高光——于是「极简」并不扁平,只是层数少一点。现在它只画一层:一次描边,配色给什么颜色就是什么颜色。
边框因此变成用户自己的选择,而不是样式硬塞的:RopeOutlineWidth 默认为 0,开启后在本体下方多描一次 本体宽度 + 2 × 描边宽度,用 RopeOutlineColor 这一种固定颜色,不参与色带。它只对 minimal 开放,设置页也只在选中 minimal 时显示这张卡片——其余样式的层都是半透明的,一圈铺在下面的描边不会只在边缘露出来,而是会透过整条绳子把颜色染暗,那不是描边,是滤镜。
EffectBounds 也要跟着加这一份:描边是唯一伸出样式自身笔宽之外的东西,脏矩形少算这一圈,屏幕上就会看到边框被裁掉一截。
同样的道理适用于绳末端那颗小圆点:它原本是「让接头在任何摆角下都毫不含糊」的保险,但绳尾几何已经保证与挂件零间隙,所以它实际上只是装饰。装饰应该可关,于是它改由 ShowRopeKnot 控制且默认关闭。
cycle 的直觉实现是「取色相、加偏移、转回 RGB」,但纯色相还原会把 #E8DCFF 这种淡紫变成刺眼的饱和紫——用户挑的明暗和鲜艳度被丢掉了。所以 ColorMath.ShiftHue 先记下原色的 min/max 包络,旋转后再把这层包络套回去。灰色没有色相可转,原样返回。
另有一个看起来多余但必需的特判:偏移为 0(速度为 0,或整整一圈)时直接返回原色。色相往返要经过 8 位通道量化,不特判的话「没有旋转」也会让颜色悄悄偏移一两个单位。
palette 沿特效循环取色,位置超过最后一种颜色时插值目标是 colors[0] 而不是被截断——否则每绕一圈都会在接缝处硬切一下。测试专门盯着接缝两侧的连续性。
aurora 一帧里要用三种色相,而画刷缓存原本只有一个槽位,于是每帧每显示器都要重建三次画刷。改为 4 槽轮转后,每种样式的配色固定不变,槽位填满后就再也不会淘汰到在用的画刷。
悬挂绳子是这个项目最反复出问题的子系统("五角星乱飞"系列 bug),稳定性由以下两层共同保证:
给第 i 个点的限位「宽容一点」看起来无害,却先后引发两个独立缺陷:
- 限位非单调 → 挂件翻转。 曾用
i * segLen * 1.15,而末端(bob)是n * segLen。只要(n-1) * 1.15 > n,也就是节数 > 7,倒数第二个点就被允许挂到比末端更远处,于是被压到末端下方:链条在底部折回,挂件方向取自这条弦,五角星被整个翻过来(最坏 180°)。 - 链条比跨距长 → 永久弓形。 改成向末端衰减的松弛后翻转消失了,但被 clamp 后的链条仍可能长于它必须跨越的距离,多出来的长度无处可去,只能向侧面弓出。实测静止收敛后链长 134.8px 而标称 132px,结果是一个等多久都不消失的 3~5px 侧弯——这正是「鼠标静止绳子也不直」的真凶。
现在限位就是 segLen * i,零松弛。半径 i·segLen 与段长 segLen 同时成立即三角不等式取等,绷紧的链条被强制共线。clamp 只会把点往内拉,所以松弛下垂的绳子完全不受影响,仍可自由卷曲。
- 段长 =
(RopeLength - IconSize) / 段数——绳子自然下垂长度即 clamp 目标,零压缩、零折叠,避免末端段方向翻转朝上。 Update的所有返回路径(正常积分 / NaN 重置 / 首帧初始化)都执行ClampToAnchor,确保悬挂物(bob)距光标永远 ≤RopeLength - IconSize。- 非破坏性:每个点独立径向 clamp,保留链条形状(旧代码重建整条链为直线,视觉上像五角星"飞走")。
- clamp 抵消被它吸收的向外径向速度,并把切向分量按半径变化缩放,使其守恒角速度。这里两个极端都错过:把速度整个清零会让绳子一绷直就失去全部动能(快速移动像根硬棍、急停不回摆);而原样保留切向速度是反向的错误——把摆动中的点往内拉却不减速等于抬高它的角速度,于是每帧都触发的 clamp 变成一个能量泵,绳子永远停不下来。
单向 Gauss-Seidel 每遍只能把张力传播 8 个连杆,长链因此整整晚一个子步才响应,无论刚度多高都显得软。改为奇偶遍交替正向 / 反向扫描后,收敛对节数的敏感度大幅下降——「18 节与 120 节轨迹一致」的测试容差从 30px 收紧到 8px 仍然通过。
MaximumPointSpeed / MaximumConstraintSpeed 是每秒像素常量,乘以子步时长使用。旧代码写成 _segmentLength * 1.5,于是上限随节数反比缩水:RopeLength=170, IconSize=38 下,18 节约 2640 px/s,120 节只剩约 396 px/s——把「节数」滑块拉满就等于把绳子锁死。
旧代码 iterations = 16 + stiffness * 32。Gauss-Seidel 在这条链上 8 次以内就收敛了,所以 16 和 48 没有可见区别,滑块从 0 拉到 1 完全无效,绳子永远是刚体。现在迭代次数固定为 8,stiffness 映射到 0.20~1.00 的约束松弛因子,得到真实的「软 → 硬」区间,顺带把最坏情况的约束运算量降到原来的约六分之一。
这条滑块过去直接就是每帧速度乘数,行程 0.85~0.99。换算一下就知道它坏在哪:乘数 d 对应的摆幅半衰期是 ln2 / (60·|ln d|),于是
| 滑块 | 半衰期 | 摆动周期 | 实测能来回摆几次 |
|---|---|---|---|
| 0.85 | 0.07 s | 1.86 s | 0 |
| 0.90(旧默认) | 0.11 s | 1.86 s | 0 |
| 0.95 | 0.23 s | 1.86 s | 1 |
| 0.99 | 1.15 s | 1.86 s | 9 |
单摆周期 2π√(L/g) 是 1.86 秒,而默认设置的半衰期只有 0.11 秒——绳子丢掉能量的速度比它完成一次摆动快十七倍,所以它根本不是在摆,而是径直爬回竖直。整条滑块的下五分之四都在这个「摆不动」的区间里,全部有效变化挤在最后几个百分点。这就是「不像真的挂坠」的根因。
现在滑块是 01 的手感控制,几何映射到 0.072.4 秒的半衰期,衰减按 0.5^(步长/半衰期) 计算(与帧率无关)。实测每四分之一行程都能改变来回摆的次数:0 / 1 / 2 / 5 / 11 次,默认 0.5 给到 2 次完整往复。设置页同时显示半衰期与摆动周期,因为只有两者之比说明问题;半衰期不足周期四分之一时会直说「绳子会直接回正而不会来回摆」。
旧文件按 滑块位置 迁移(v3→v4),不是按旧物理:忠实还原旧数值等于忠实还原一根摆不动的绳子,而那正是被修掉的东西。旧的两端仍是两端,旧默认落在新默认上。
上一节把摆动持续修成了半衰期,但比值的另一半——摆动周期——同样是被随手定的。旧默认重力 1500 px/s² 在 132px 的绳子上给出 1.86 秒周期,而这正是一根七十厘米长的摆的周期。屏幕上画的是三厘米出头的一小截绳子,动起来却像七十厘米,于是整个挂件永远像泡在水里。旧重力滑块的上限 3000 也只到 1.32 秒,所以这不是"默认值不合适",是整条滑块都在慢动作区。
问题的根子是:像素和秒之间少了一个换算。屏幕像素只是一个比例因子,先定下"一个像素值多少米",重力就被一起定死了:
比例 = 图标像素 / 挂坠真实尺寸(0.03 m)
重力(px/s²) = 9.81 × 比例
锚在挂件上而不是屏幕 DPI 上,是因为挂件的真实尺寸是一个可以检查的说法(那张小猪图画的就是个三厘米的手机挂坠),而且这样长度与重力会一起缩放,周期在整个设置空间里都待在"真实挂坠"的区间:
| 悬挂总长 | 图标 | 反推重力 | 摆动周期 | 实测来回次数 |
|---|---|---|---|---|
| 170 | 38(默认) | 12426 | 0.65 s | 8 |
| 500 | 38 | 12426 | 1.21 s | 9 |
| 170 | 10 | 3270 | 1.39 s | 6 |
| 170 | 120 | 39240 | 0.22 s | 3 |
| 500 | 120 | 39240 | 0.62 s | 8 |
最后两行看着极端,其实都对:120px 的图标配 170px 总长,等于"十厘米的挂坠吊在四厘米的绳子上",现实中就是会抖得很快。有测试直接拿 2π√(L/g) 的实数解去比对这四种几何。
"重"不是靠摆得慢。 单摆周期与质量完全无关,所以让绳子看起来吊着重物,靠的是另外三件事:绳子绷得笔直不可拉伸(刚度拉满)、动起来就不轻易停(摆动持续 0.75,约 1 秒半衰期)、以及挂件的惯性滞后——这三项就是「恢复真实物理」按钮写的全部内容。它刻意不碰长度、节数、图标大小、样式和配色:那些决定绳子长什么样,是用户的审美选择,而重力本来就是从它们反推出来的。出厂默认值现在直接就是这套反推结果,所以"默认"和"最符合物理"是同一个东西,不会出现按钮和 Reset() 给出两套数的尴尬。
距离约束是对称的:链段被压短时它会把两端推开。真绳不会推。后果是把光标直接压向挂件时,挂件被当成装在硬杆上一样顶走——实测光标下落 99px,挂件被推下 92px。
现在压缩方向的刚度只有拉伸方向的 15%,同一场景挂件只落 61px(其中大部分是它自己在重力下的自由落体),绳子按预期松弛下来。不取 0 是因为一个链段代表的是一小段连续绳子的弧,真实的弧会向外弓而不是原地对折;留一点推力,松弛才会变成弯曲而不是一堆点挤在一起。
顺带一提,这个缺陷只在完全对称的场景下才严重:横向哪怕只有 1px 的分量,多余长度就会正常地弓向侧面,新旧行为几乎一致(挂件下落 18px vs 19px)。
- 给挂件更大的质量(按逆质量分配约束修正,让轻绳被重挂坠拉直)。听起来完全正确,实测几乎没有效果:在锚点固定的链条上,收敛后的构形由约束几何主导,质量分配只影响暂态路径。绳子对光标的滞后只变化 0.4%,而大图标的摆幅反而略微变小。加一层概念却换不到可测量的收益,不如不加。
- 让约束迭代次数随节数增长(8→48),理由是单次扫描只能把张力传一个连杆。实测同样无效:交替扫描下 120 节的挂件已经在第一帧就响应,18 节与 120 节的差距一分未减,而整个测试套件耗时几乎翻倍。
18 节与 120 节的剩余差异是真实的离散化误差,不是 bug:细分越密越收敛(平均偏移差 18→40 节 12.2px、40→80 节 3.1px、80→120 节 0.6px)。所以那条测试改成验证「加密必须收敛」,而不是要求粗细两种离散化给出同一个瞬时位置——后者在一根允许摆动的绳子上本来就不是不变量,测到的只是摆动的相位。
Assets/pig.png 的 alpha 通道整张都是 255:那张「透明网格」不是透明,而是被烙进像素的灰白方格。挂到光标下就是一个方块,不是小猪。
抠图不能靠坐标推算格子颜色——原图被重采样过,格子边界不落在整像素上,行程实测是 12/19/18/20/19… 且纵向出现过 40 像素的连续同色段。所以按颜色判定(接近中性灰且亮度落在网格色区间),再只保留从图像边框能走到的那些像素:小猪眼睛里的白色高光同样是中性色,但它进不来,于是不会被误伤。
两道收尾:把透明区域向内扩 2px,吃掉那圈混了灰的反锯齿边;然后把主体颜色向外渗进这圈,羽化时才是粉色淡出而不是灰色淡出。最后只保留最大的连通块——四格交界处重采样出来的中间灰逃过了颜色判定,会剩下零星小方块,与其把颜色阈值越放越宽,不如直接丢掉不属于主体的碎片。
girl.png 有同样的毛病,暂未处理:她的头发是浅色且触到画面边缘,同一套阈值会留下淡淡的残迹,需要单独调。
悬挂物局部坐标的原点 (0,0) 是其顶点(焊在绳子末端)。System.Numerics 用 row-vector(v' = v*M),变换矩阵必须是 R * T:
v' = v · (R · T) = R · v + Tip
这样 local(0,0) → Tip(顶点锚定绳末端,永不分离),local(0, size) → Tip + Direction*size(沿绳方向延伸)。
历史教训:旧代码写成
T * R,展开是(v + Tip) · R——把已平移的点绕屏幕原点旋转。鼠标不动时angle≈0看似正常,一旦绳子摆动angle≠0,五角星就被绕屏幕原点甩到rotate(Tip)处,正是"鼠标移动后五角星飞离指针"的根因。
挂件方向沿绳回溯到第一个距末端至少 max(6, IconSize*0.35) 像素的点取弦(跨度封顶为链长的一半)。直接用 pts[^1] - pts[^2] 在 40 节 + 短绳时基线只有两三像素,方向基本是数值噪声,挂件会肉眼可见地抖。
但只改方向不够:绳子照原样逐点描边的话,画出来的最后一段和挂件的轴不再相切,接缝处会出现折角。所以 RopePath.Build 在生成描边用的折线时,把绳尾按 smoothstep 权重渐变地压到这条轴上——最靠近接缝的那个点完全落在轴上(末段与挂件轴严格平行,零折角),到弦的基点权重衰减为 0(不会把折角往上挪一段)。首尾两点绝不移动,保底 reach 约束不受影响。
这也是物理上说得通的:焊死在绳末端的刚性挂件本来就会让最后一小段绳子变硬。
RopeSimulatorTests验证静止收敛、瞬移、剧烈抖动、完整挂件不越界,以及急停后 18/120 节都必须继续摆动、RopeStiffness必须产生可见差异。PendantGeometryTests直接验证连接原点、方向、非法输入回退和R*T语义。EffectBoundsTests验证脏矩形包围盒始终包住挂件顶点,以及按显示器裁剪的正确性。SimulationRegressionTests在输入突发、运行中绳子参数变化及长时间高复杂度模拟下验证容量、有限值、长度和性能预算。- 这些测试通过标准
dotnet test运行,不再依赖应用内的--test-*调试入口(相关调试代码已删除)。