Skip to content

Latest commit

 

History

31 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

FunnyCursor

一款 Windows 平台的鼠标美化工具,基于 WinUI 3 + Win2D 构建。它在屏幕上叠加一个全屏透明、可点击穿透的图层,实时渲染鼠标点击特效、悬挂绳物理、光标拖尾与光晕,并常驻系统托盘。

功能特性

  1. 点击特效(7 种预设)

    • 全局低级鼠标钩子(WH_MOUSE_LL)检测左右中键点击,在点击位置触发动画。
    • sparkle 闪烁粒子、confetti 彩色纸屑、firework 烟花绽放、shockwave 冲击波、bubble 上浮气泡(反向重力)、ring 扩散光环、ripple 水波纹。
    • 可调节粒子数量、喷射速度、重力、颜色。每个滑块对每个预设都有作用:光环类预设把「粒子数量」映射为圈数、「喷射速度」映射为扩散半径;这两个预设没有粒子,所以重力滑块会被置灰而不是静默失效。
  2. 悬挂绳子 + 图标(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²),并把绳子设为不可拉伸、摆动能持续好几个来回。只改重力 / 刚度 / 摆动持续三项,长度、节数、图标、样式、配色都原样保留,按完也照样能继续手调。原理见下方「像素到米:重力不是随便选的」。
  3. 光标拖尾(4 种样式)

    • 记录光标轨迹,按存活时间淡出,可调节配色、长度、宽度。
    • comet 彗星拖尾(渐窄的锥形)、ribbon 丝带飘带(宽度几乎不收窄,靠透明度消散)、dots 光点串珠、spark 电光火花(白热芯线包在宽辉光里)。
    • 取色参数是存活时间比例,而不是样本序号,所以指针快慢不会拉伸或压缩配色。
    • 轨迹取自鼠标每一次硬件上报(125~1000 Hz),而非每帧轮询一次,因此快速画圆画弧不会退化成折线。
    • 拖尾是分段整条描边画出来的,不是每个采样描一条线。后者会在每个采样点把圆头帽合成两次,在拖尾中间留下一串小圆点,尾部越细越明显(见下方「拖尾也栽在同一个圆头帽上」)。
    • 平滑(0~1,默认 0.65):沿轨迹做一次滤波,把上报间隔造成的折角磨圆。上报频率跟不上画的速度时,轨迹是真的多边形——转角是真实存在的,只是手没那样拐过。光标处不受影响,所以拖尾不会和指针脱开。见下方「多边形不是拖尾的错,但得由拖尾来收拾」。
  4. 星云环绕(7 种环绕方式)

    • galaxy 旋臂星系(四条旋臂的倾斜星盘)、orbit 同心圆环、sphere 立体星球、atom 原子轨道、helix 双螺旋、vortex 旋涡吸入、chaos 随机乱舞。
    • sphere / atom / helix / chaos真 3D:粒子带深度坐标,按深度排序绘制,背面自动变小变暗(透视),正面压在背面之上。
    • vortex 会回收粒子——星尘沿螺旋向外流动、到边缘淡出后从中心重新出现,淡出同时也会掐掉尾迹(见下方「星云环绕方式」)。
    • 星尘、云雾、彗星尾迹与星尘光晕围绕光标旋转(支持正反向)。
    • 鸟群跟随(可选,默认关闭):星云不再焊死在光标上,每颗星尘各自追赶光标。甩动鼠标会把它们抛在身后,然后陆续飞回来;有硬性的最大甩开距离,越接近这个上限追回来的加速度越大。详见下方「鸟群跟随」。
    • 每颗星尘为三层同心结构:中心点 → 描边(可设颜色/宽度/不透明度)→ 最外层柔和光晕。
    • 星尘本体与它们的彗星尾迹走统一配色(取色参数是粒子的轨道半径,所以内圈外圈可以是不同颜色);云雾、描边、光晕是独立图层,各自保留自己的纯色。
    • 颜色统一使用 RGB;云雾、尾迹、星尘本体、描边、星尘光晕分别提供明确的不透明度,描边有独立宽度、光晕有独立大小控制。
  5. 光标光晕(4 种样式)

    • soft 柔和光斑(中心亮、边缘柔和衰减,带极轻微呼吸)、ring 光环(中空圆环紧贴光标)、aurora 极光流转(两团光斑绕光标此起彼伏)、pulse 心跳脉冲(半径与亮度一起明显起伏)。
    • 可调节配色、半径、强度。强度只改不透明度,不会改变几何。
  6. 统一配色(6 种配色模式 × 8 套调色板)

    • 点击特效、绳子、拖尾、星云星尘、光晕共用同一套配色引擎,所以「彩虹流光」在五处的含义完全一致。
    • solid 单色、gradient 双色渐变、rainbow 彩虹流光(色相沿特效扫过并随时间流动)、palette 多色流动(调色板里的几种颜色依次循环)、breathe 呼吸渐变(整体在主色与辅色之间往复)、cycle 循环变色(缓慢旋转主色色相,保留原本的明暗与鲜艳度)。
    • 8 套调色板:极光 / 霓虹 / 日落 / 深海 / 烈焰 / 糖果 / 赛博 / 草野。
    • 样式与配色彻底解耦:过去「彩虹拖尾」是一种样式,于是彩虹只能配彗星形状、丝带永远无法彩虹。现在任意样式都能搭配任意配色,组合数从「6 选 1」变成 4×6。旧设置在加载时自动迁移(rainbow 拖尾 → comet 样式 + rainbow 配色,confetti 点击 → 糖果调色板)。
    • 设置页每个配色卡片顶部都有一条实时动画预览,直接显示这套参数画出来是什么样,不必靠模式名去猜。
    • 不适用的控件会隐藏而不是留在那里失效:单色模式不显示辅色与速度,彩虹模式不显示颜色,只有多色流动才出现调色板。
  7. 系统托盘 + 开机自启

    • 最小化到托盘而非退出;左键 / 双击托盘图标打开设置面板,右键弹出菜单(打开 / 退出)。
    • 设置内可开启开机自启(写入注册表 Run 键)。
  8. 关于面板

    • 设置面板底部新增「关于」分组,集中展示产品名、版本号、作者、版权与仓库地址。
    • 版本号旁可一键复制;仓库地址可一键在浏览器打开。

技术栈

项目 说明
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=NoneWindowsAppSDKSelfContained=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 命令行

Debug

cd MouseBeautifier
dotnet build -c Debug -p:Platform=x64

Release

cd 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.exeFunnyCursor.dllFunnyCursor.Core.dll
  • FunnyCursor.pri(应用与 WinUI 资源索引)
  • App.xbfSettingsWindow.xbf(已编译 XAML)
  • Microsoft.WindowsAppRuntime.dllMicrosoft.ui.xaml.dll 与其他自包含 Windows App SDK 文件
  • Assets/funnycursor.icoAssets/pig.pngAssets/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 绳子落到 neonconfetti 点击落到糖果调色板;已经显式写了配色模式的文件不被二次改写;当前版本的文件完全不迁移。
  • 样式声明的绘制范围必须真实:脏矩形是按 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

使用说明

  1. 启动后托盘区出现图标,屏幕上即开始显示美化效果。
  2. 左键双击托盘图标(或右键 → 打开面板)打开设置窗口。
  3. 在设置窗口中实时调整各项参数,修改即时生效。
  4. 关闭设置窗口会最小化到托盘;从托盘菜单选择“退出”才真正结束程序。
  5. 全局快捷键 Ctrl+Shift+Q 可随时退出程序(即使设置面板未打开)。
    • 若该快捷键被其他程序占用,会自动回退到 Ctrl+Alt+Q

设置项说明

分组 设置 说明
点击特效 启用 / 预设(7 种)/ 配色 / 粒子数 / 速度 / 重力 控制点击动画;光环类预设把粒子数当圈数、速度当半径,且重力滑块置灰。配色的取色参数是每颗粒子的随机种子(光环则是扩散进度)
绳子 启用 / 悬挂总长度 / 节数 / 重力 / 摆动持续 / 刚度 / 样式(5 种)/ 配色 / 粗细 / 图标类型(含内置图片选项) / 大小 / 颜色 / 自定义路径 控制悬挂物理。配色是沿绳连续的渐变,不随节数分段。「摆动持续」是 01 的手感控制,映射到摆幅半衰期 0.072.4 秒,越大摆得越久;面板同时显示半衰期与单次摆动周期,因为只有两者之比有意义。重力下限从 0 提到 100(重力是把绳子拉回竖直的唯一力,为 0 就会停在上次被甩到的姿态),上限从 3000 提到 40000——旧上限下整条滑块最慢也才 1.3 秒周期,全程都是慢动作。卡片底部的「恢复真实物理」按钮按当前图标大小反推这三项。「悬挂总长度」含图标。配色沿绳子由光标向挂件取色
拖尾 启用 / 样式(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 倍半径,所以兜底不会碰到正常值,但任何将来带接缝的新方式都不会再把脏矩形撑到全屏。

折叠后的旋转角只能喂给三角函数

SpinIEEERemainder 把无界的累计角折进一圈,避免单精度在长时间运行后变粗。代价是这个折叠本身是一次 360° 跳变——其余方式只把它送进 sin/cos,周期性让跳变完全不可见,但 vortex 还用它驱动半径,于是每次折叠整条螺旋都会跳一下。因此 vortex 的推进速率被约束为每转整数圈VortexPassesPerTurn),让折叠正好落在螺旋的周期边界上。

鸟群跟随:只让锚点滞后,不让画面滞后

直觉做法是对最终位置做低通滤波。这是错的:粒子的最终位置里已经包含了轨道运动,滤掉高频等于把轨道本身也一起阻尼掉——鼠标一动,星云就会缩小半径、转速变慢,停下才慢慢恢复。

所以滞后的是锚点:每颗星尘绕着自己的锚点走完全精确的轨道,只有锚点在追光标。轨道几何一个字都不用改,NebulaLayout.Generate 只是多收一段锚点数组;数组为空就等于「焊死在光标上」,也就是关掉这个开关时逐位相同的老行为。

为什么是每颗一个锚点,而不是一个共享锚点。 共享锚点只会让整张图平移,看起来像在拖一张贴图。每颗星尘有自己的弹簧速率(Agility,0.62~1.38 的确定性哈希),快的先到、慢的殿后,这个先后差才是「鸟群」的观感来源。

「离得越远赶得越快」需要额外一项。 线性弹簧在固定时间内追回的是同样的比例,所以绝对速度确实随距离上升,但相对进度不变——甩得远的看起来反而更久回不来。因此速率里乘上 1 + 1.6 × (距离 / 上限):越靠近上限,弹簧越硬,落在最后的那批加速度最大,队形会自己收拢而不是越拉越散。

阻尼是指数衰减而不是 -2ζωv 教科书那一项在 速率 × 步长 超过 1 左右时会把速度反号,而「追赶速度」滑块推到最大时确实能到这个区间。指数衰减无条件稳定。另外 速率 × 步长 有一道 1.2 的硬上限:半隐式欧拉在 2 附近发散,这道上限保证以后改常数或改步长都不会把星尘甩出屏幕,最坏情况只是追得慢一点。

上限是硬夹的,但夹的时候只扣掉逃逸分量。 把整个速度清零会让贴着上限跑的粒子看起来撞在一堵墙上;只减掉朝外的那部分,它就是被绳子拖着走。

云雾不是逐粒子画的,而是三个解析椭圆,所以它需要自己的中心——取所有锚点的质心。用质心而不是再跑一根弹簧,是为了让它在定义上不可能和它本该笼罩的星尘分家。EffectBounds 的云雾包围盒也跟着改到这个中心,否则脏矩形会把飘在后面的那团雾裁掉一截。

彗星尾迹仍然只由轨道速度决定,不掺锚点速度。追赶时星尘不会拖出朝向运动方向的条纹——这是有意保守:尾迹长度是脏矩形的输入之一,多一个速度来源就多一条需要单独兜底的失控路径。

3D 方式的透视

sphere / atom / helix / chaos 携带归一化到 −1..1 的深度:既作为画家排序键,也映射成 0.45~1 的尺寸与亮度缩放,背面因此自动变小变暗。sphere 用黄金角(Fibonacci 球)分布,避免退化成一叠纬度带。平面方式的深度缩放恒为 1,所以 galaxy 的观感与引入这套机制之前逐位相同

统一配色引擎

样式描述形状,ColorRamp 描述颜色

过去「彩虹」是一种拖尾样式,也是一种绳子样式。这个划分从一开始就是错的:彩虹讲的是颜色怎么取,彗星讲的是线条怎么收窄,两者本来正交。把它们塞进同一个枚举的代价是组合被锁死——想要彩虹的丝带、想要渐变的串珠,都做不到,而且同一个「彩虹」在拖尾和绳子里是两份独立实现,可以各自跑偏。

现在 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/R px,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 旋转色相,但不能把淡色转成霓虹

cycle 的直觉实现是「取色相、加偏移、转回 RGB」,但纯色相还原会把 #E8DCFF 这种淡紫变成刺眼的饱和紫——用户挑的明暗和鲜艳度被丢掉了。所以 ColorMath.ShiftHue 先记下原色的 min/max 包络,旋转后再把这层包络套回去。灰色没有色相可转,原样返回。

另有一个看起来多余但必需的特判:偏移为 0(速度为 0,或整整一圈)时直接返回原色。色相往返要经过 8 位通道量化,不特判的话「没有旋转」也会让颜色悄悄偏移一两个单位。

调色板跨越末尾时要回到首色

palette 沿特效循环取色,位置超过最后一种颜色时插值目标是 colors[0] 而不是被截断——否则每绕一圈都会在接缝处硬切一下。测试专门盯着接缝两侧的连续性。

光晕画刷池

aurora 一帧里要用三种色相,而画刷缓存原本只有一个槽位,于是每帧每显示器都要重建三次画刷。改为 4 槽轮转后,每种样式的配色固定不变,槽位填满后就再也不会淘汰到在用的画刷。

绳子物理与稳定性

悬挂绳子是这个项目最反复出问题的子系统("五角星乱飞"系列 bug),稳定性由以下两层共同保证:

0. 保底 clamp 绝不能给中间点留松弛(踩过两次的坑)

给第 i 个点的限位「宽容一点」看起来无害,却先后引发两个独立缺陷:

  1. 限位非单调 → 挂件翻转。 曾用 i * segLen * 1.15,而末端(bob)是 n * segLen。只要 (n-1) * 1.15 > n,也就是节数 > 7,倒数第二个点就被允许挂到比末端更远处,于是被压到末端下方:链条在底部折回,挂件方向取自这条弦,五角星被整个翻过来(最坏 180°)。
  2. 链条比跨距长 → 永久弓形。 改成向末端衰减的松弛后翻转消失了,但被 clamp 后的链条仍可能长于它必须跨越的距离,多出来的长度无处可去,只能向侧面弓出。实测静止收敛后链长 134.8px 而标称 132px,结果是一个等多久都不消失的 3~5px 侧弯——这正是「鼠标静止绳子也不直」的真凶。

现在限位就是 segLen * i,零松弛。半径 i·segLen 与段长 segLen 同时成立即三角不等式取等,绷紧的链条被强制共线。clamp 只会把点往内拉,所以松弛下垂的绳子完全不受影响,仍可自由卷曲。

1. 物理层保底 clamp(RopeSimulator.ClampToAnchor

  • 段长 = (RopeLength - IconSize) / 段数——绳子自然下垂长度即 clamp 目标,零压缩、零折叠,避免末端段方向翻转朝上。
  • Update 的所有返回路径(正常积分 / NaN 重置 / 首帧初始化)都执行 ClampToAnchor,确保悬挂物(bob)距光标永远 ≤ RopeLength - IconSize
  • 非破坏性:每个点独立径向 clamp,保留链条形状(旧代码重建整条链为直线,视觉上像五角星"飞走")。
  • clamp 抵消被它吸收的向外径向速度,并把切向分量按半径变化缩放,使其守恒角速度。这里两个极端都错过:把速度整个清零会让绳子一绷直就失去全部动能(快速移动像根硬棍、急停不回摆);而原样保留切向速度是反向的错误——把摆动中的点往内拉却不减速等于抬高它的角速度,于是每帧都触发的 clamp 变成一个能量泵,绳子永远停不下来。

1a. 约束求解交替扫描方向

单向 Gauss-Seidel 每遍只能把张力传播 8 个连杆,长链因此整整晚一个子步才响应,无论刚度多高都显得软。改为奇偶遍交替正向 / 反向扫描后,收敛对节数的敏感度大幅下降——「18 节与 120 节轨迹一致」的测试容差从 30px 收紧到 8px 仍然通过。

1b. 速度上限用 px/s,不用段长

MaximumPointSpeed / MaximumConstraintSpeed每秒像素常量,乘以子步时长使用。旧代码写成 _segmentLength * 1.5,于是上限随节数反比缩水:RopeLength=170, IconSize=38 下,18 节约 2640 px/s,120 节只剩约 396 px/s——把「节数」滑块拉满就等于把绳子锁死。

1c. RopeStiffness 是松弛系数,不是迭代次数

旧代码 iterations = 16 + stiffness * 32。Gauss-Seidel 在这条链上 8 次以内就收敛了,所以 16 和 48 没有可见区别,滑块从 0 拉到 1 完全无效,绳子永远是刚体。现在迭代次数固定为 8,stiffness 映射到 0.20~1.00 的约束松弛因子,得到真实的「软 → 硬」区间,顺带把最坏情况的约束运算量降到原来的约六分之一。

1d. 「摆动持续」是半衰期,不是速度保留系数

这条滑块过去直接就是每帧速度乘数,行程 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),不是按旧物理:忠实还原旧数值等于忠实还原一根摆不动的绳子,而那正是被修掉的东西。旧的两端仍是两端,旧默认落在新默认上。

1d-2. 像素到米:重力不是随便选的

上一节把摆动持续修成了半衰期,但比值的另一半——摆动周期——同样是被随手定的。旧默认重力 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() 给出两套数的尴尬。

1e. 绳子只拉不推

距离约束是对称的:链段被压短时它会把两端推开。真绳不会推。后果是把光标直接压向挂件时,挂件被当成装在硬杆上一样顶走——实测光标下落 99px,挂件被推下 92px。

现在压缩方向的刚度只有拉伸方向的 15%,同一场景挂件只落 61px(其中大部分是它自己在重力下的自由落体),绳子按预期松弛下来。不取 0 是因为一个链段代表的是一小段连续绳子的弧,真实的弧会向外弓而不是原地对折;留一点推力,松弛才会变成弯曲而不是一堆点挤在一起。

顺带一提,这个缺陷只在完全对称的场景下才严重:横向哪怕只有 1px 的分量,多余长度就会正常地弓向侧面,新旧行为几乎一致(挂件下落 18px vs 19px)。

1f. 试过但测量后放弃的两件事

  • 给挂件更大的质量(按逆质量分配约束修正,让轻绳被重挂坠拉直)。听起来完全正确,实测几乎没有效果:在锚点固定的链条上,收敛后的构形由约束几何主导,质量分配只影响暂态路径。绳子对光标的滞后只变化 0.4%,而大图标的摆幅反而略微变小。加一层概念却换不到可测量的收益,不如不加。
  • 让约束迭代次数随节数增长(8→48),理由是单次扫描只能把张力传一个连杆。实测同样无效:交替扫描下 120 节的挂件已经在第一帧就响应,18 节与 120 节的差距一分未减,而整个测试套件耗时几乎翻倍。

18 节与 120 节的剩余差异是真实的离散化误差,不是 bug:细分越密越收敛(平均偏移差 18→40 节 12.2px、40→80 节 3.1px、80→120 节 0.6px)。所以那条测试改成验证「加密必须收敛」,而不是要求粗细两种离散化给出同一个瞬时位置——后者在一根允许摆动的绳子上本来就不是不变量,测到的只是摆动的相位。

1g. 内置图片的透明背景

Assets/pig.png 的 alpha 通道整张都是 255:那张「透明网格」不是透明,而是被烙进像素的灰白方格。挂到光标下就是一个方块,不是小猪。

抠图不能靠坐标推算格子颜色——原图被重采样过,格子边界不落在整像素上,行程实测是 12/19/18/20/19… 且纵向出现过 40 像素的连续同色段。所以按颜色判定(接近中性灰且亮度落在网格色区间),再只保留从图像边框能走到的那些像素:小猪眼睛里的白色高光同样是中性色,但它进不来,于是不会被误伤。

两道收尾:把透明区域向内扩 2px,吃掉那圈混了灰的反锯齿边;然后把主体颜色向外渗进这圈,羽化时才是粉色淡出而不是灰色淡出。最后只保留最大的连通块——四格交界处重采样出来的中间灰逃过了颜色判定,会剩下零星小方块,与其把颜色阈值越放越宽,不如直接丢掉不属于主体的碎片。

girl.png 有同样的毛病,暂未处理:她的头发是浅色且触到画面边缘,同一套阈值会留下淡淡的残迹,需要单独调。

2. 渲染层矩阵顺序(EffectRenderer.DrawRope

悬挂物局部坐标的原点 (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) 处,正是"鼠标移动后五角星飞离指针"的根因。

3. 挂件朝向取弦,绳尾贴合该弦(RopePath

挂件方向沿绳回溯到第一个距末端至少 max(6, IconSize*0.35) 像素的点取弦(跨度封顶为链长的一半)。直接用 pts[^1] - pts[^2] 在 40 节 + 短绳时基线只有两三像素,方向基本是数值噪声,挂件会肉眼可见地抖。

但只改方向不够:绳子照原样逐点描边的话,画出来的最后一段和挂件的轴不再相切,接缝处会出现折角。所以 RopePath.Build 在生成描边用的折线时,把绳尾按 smoothstep 权重渐变地压到这条轴上——最靠近接缝的那个点完全落在轴上(末段与挂件轴严格平行,零折角),到弦的基点权重衰减为 0(不会把折角往上挪一段)。首尾两点绝不移动,保底 reach 约束不受影响。

这也是物理上说得通的:焊死在绳末端的刚性挂件本来就会让最后一小段绳子变硬。

4. 测试覆盖

  • RopeSimulatorTests 验证静止收敛、瞬移、剧烈抖动、完整挂件不越界,以及急停后 18/120 节都必须继续摆动、RopeStiffness 必须产生可见差异。
  • PendantGeometryTests 直接验证连接原点、方向、非法输入回退和 R*T 语义。
  • EffectBoundsTests 验证脏矩形包围盒始终包住挂件顶点,以及按显示器裁剪的正确性。
  • SimulationRegressionTests 在输入突发、运行中绳子参数变化及长时间高复杂度模拟下验证容量、有限值、长度和性能预算。
  • 这些测试通过标准 dotnet test 运行,不再依赖应用内的 --test-* 调试入口(相关调试代码已删除)。

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages