RPE 速度事件缓动的研究与适配
This article is part of Blogs in May 2025.
在 RPE 1.6.2 中,速度事件的定义得到了扩展,首次允许其拥有缓动类型(easingTypeeasingType)与缓动截取区间(easingLefteasingLeft,easingRighteasingRight)。
“ 1.6.2 Changelog
Added:
- 速度缓动 (见注记)
- 自定义预制事件映射 (配置文件为
./Resources/prefab_mapper.json./Resources/prefab_mapper.json,默认 E 键生成一组重置事件) - 可设置自动保存文件数量上限
- 上传至局域网服务器 (见注记)
- 可从 osu 和 malody 的谱面文件 (osz & mcz) 中导入 BPM 列表
- 选中单个音符时 (单选或多选),
Ctrl+CCtrl+C会将音符的 x 坐标填入系统剪切板 (可选) - 基本信息中显示创建和最后编辑时间
- 剪切板起点时间线染色 (可选)
Fixed:
- 现在点线切换选项也决定是否点音符换线
- 框选时点击事件不再会进行单选
Adjustments:
- 现在曲线填充快捷键 (默认
Ctrl+F/GCtrl+F/G) 会直接打开曲线填充面板
注记:
- 约定:速度事件缓动不为 1 时,实际的速度变化与缓动的导函数形状相同,从而 floorposition 的变化遵循缓动曲线。为了兼容性,缓动为 1 时我们保持原含义不变,也即缓动为 1 和缓动为 5 都代表二次型的 floorposition 变化
- 端口始终为 8080,上传文件名始终为 test.pez。在软件关闭前可在同局域网下的其他设备上下载谱面
- RPEVersion 已更新至 162
”
具体地,除了缓动类型为线性(easingType = 1easingType = 1)的情况照旧,在设置成其余缓动类型(easingType > 1easingType > 1)时,note 速度将按照所设置的缓动函数的导函数进行变化。
然而,导函数并不一定满足成为缓动函数的条件,需要对其进行额外的变换,从而导致这样的映射关系并不能保证 note 高度会随原函数进行变化。因此我们需要从定义出发,首先确定缓动函数的导函数及其变换,然后再通过积分运算得到符合速度规律的原函数作为 note 高度的变化依据。
缓动导函数的确定与归一化
注意到,缓动函数一般需要满足:定义域 ,起始值为 ,终止值为 。
设缓动原函数为
则我们需要找到一个 使得
为了普遍性(同时为了确保精度),不妨设 。由
我们不难得到
由此,note 速度便可以表示为
代入 ,有
实际缓动函数的确定
在代码实现中,我们需要知道 note 高度的函数,也就是对 进行积分:
由此,我们只需要知道 即可计算出 note 高度。
代码实现
现成的速度事件处理逻辑:
export const getIntegral = (
event: SpeedEvent | undefined,
bpmList: Bpm[],
beat: number | undefined = undefined,
): number => {
if (!event) return 0;
if (beat === undefined || beat >= event.endBeat) beat = event.endBeat;
const lengthSec = getTimeSec(bpmList, event.endBeat) - getTimeSec(bpmList, event.startBeat);
return ((event.start + (getEventValue(beat, event) as number)) * lengthSec) / 2;
};
export const getIntegral = (
event: SpeedEvent | undefined,
bpmList: Bpm[],
beat: number | undefined = undefined,
): number => {
if (!event) return 0;
if (beat === undefined || beat >= event.endBeat) beat = event.endBeat;
const lengthSec = getTimeSec(bpmList, event.endBeat) - getTimeSec(bpmList, event.startBeat);
return ((event.start + (getEventValue(beat, event) as number)) * lengthSec) / 2;
};
export const getIntegral = (
event: SpeedEvent | undefined,
bpmList: Bpm[],
beat: number | undefined = undefined,
): number => {
if (!event) return 0;
if (beat === undefined || beat >= event.endBeat) beat = event.endBeat;
const lengthSec = getTimeSec(bpmList, event.endBeat) - getTimeSec(bpmList, event.startBeat);
return ((event.start + (getEventValue(beat, event) as number)) * lengthSec) / 2;
};
export const getIntegral = (
event: SpeedEvent | undefined,
bpmList: Bpm[],
beat: number | undefined = undefined,
): number => {
if (!event) return 0;
if (beat === undefined || beat >= event.endBeat) beat = event.endBeat;
const lengthSec = getTimeSec(bpmList, event.endBeat) - getTimeSec(bpmList, event.startBeat);
return ((event.start + (getEventValue(beat, event) as number)) * lengthSec) / 2;
};
其中,getTimeSecgetTimeSec 用于获取指定拍数所对应的时间,getEventValuegetEventValue 用于获取指定拍数时指定事件的值。
接下来,我们对速度事件的缓动进行处理。
-
首先判断需要使用到缓动处理的情况:
if ('easingType' in event && event.easingType > 1) {
// 需要处理缓动
}if ('easingType' in event && event.easingType > 1) {
// 需要处理缓动
}if ('easingType' in event && event.easingType > 1) {
// 需要处理缓动
}if ('easingType' in event && event.easingType > 1) {
// 需要处理缓动
} -
然后计算缓动原函数端点处的导数值 :
const df0 = derivative(event.easingType, 0);
const df1 = derivative(event.easingType, 1);
const df0 = derivative(event.easingType, 0);
const df1 = derivative(event.easingType, 1);
const df0 = derivative(event.easingType, 0);
const df1 = derivative(event.easingType, 1);
const df0 = derivative(event.easingType, 0);
const df1 = derivative(event.easingType, 1);
其中
derivativederivative函数负责计算缓动函数的导数值,大致定义如下:export const derivative = (
type: number,
x: number,
epsilon = 1e-12,
) => {
const leftX = Math.max(1e-16, x - epsilon);
const rightX = Math.min(1 - 1e-16, x + epsilon);
const leftY = calculateEasingValue(type, leftX);
const rightY = calculateEasingValue(type, rightX);
return (rightY - leftY) / (rightX - leftX);
};
export const derivative = (
type: number,
x: number,
epsilon = 1e-12,
) => {
const leftX = Math.max(1e-16, x - epsilon);
const rightX = Math.min(1 - 1e-16, x + epsilon);
const leftY = calculateEasingValue(type, leftX);
const rightY = calculateEasingValue(type, rightX);
return (rightY - leftY) / (rightX - leftX);
};
export const derivative = (
type: number,
x: number,
epsilon = 1e-12,
) => {
const leftX = Math.max(1e-16, x - epsilon);
const rightX = Math.min(1 - 1e-16, x + epsilon);
const leftY = calculateEasingValue(type, leftX);
const rightY = calculateEasingValue(type, rightX);
return (rightY - leftY) / (rightX - leftX);
};
export const derivative = (
type: number,
x: number,
epsilon = 1e-12,
) => {
const leftX = Math.max(1e-16, x - epsilon);
const rightX = Math.min(1 - 1e-16, x + epsilon);
const leftY = calculateEasingValue(type, leftX);
const rightY = calculateEasingValue(type, rightX);
return (rightY - leftY) / (rightX - leftX);
};
其中
calculateEasingValuecalculateEasingValue函数用于获取指定类型的缓动函数在指定进度的值,epsilonepsilon用于控制计算精度。
注意:此处为了避免缓动函数有时会在端点处特判的情况,将自变量的取值范围限制在了 内。 -
计算 :
const k = (event.end - event.start) / (df1 - df0);
const c = event.start - k * df0;
const k = (event.end - event.start) / (df1 - df0);
const c = event.start - k * df0;
const k = (event.end - event.start) / (df1 - df0);
const c = event.start - k * df0;
const k = (event.end - event.start) / (df1 - df0);
const c = event.start - k * df0;
此处直接将 取为事件的起始值与终止值,从而减少计算量,提高浮点计算精度。
-
计算缓动值(note 高度):
const x = (beat - event.startBeat) / (event.endBeat - event.startBeat);
const progress = integrate(event.easingType, x, k, c);
const x = (beat - event.startBeat) / (event.endBeat - event.startBeat);
const progress = integrate(event.easingType, x, k, c);
const x = (beat - event.startBeat) / (event.endBeat - event.startBeat);
const progress = integrate(event.easingType, x, k, c);
const x = (beat - event.startBeat) / (event.endBeat - event.startBeat);
const progress = integrate(event.easingType, x, k, c);
其中,
integrateintegrate函数定义如下:export const integrate = (
type: number,
x: number,
k: number,
c: number,
) => {
return k * calculateEasingValue(type, x) + c * x;
};
export const integrate = (
type: number,
x: number,
k: number,
c: number,
) => {
return k * calculateEasingValue(type, x) + c * x;
};
export const integrate = (
type: number,
x: number,
k: number,
c: number,
) => {
return k * calculateEasingValue(type, x) + c * x;
};
export const integrate = (
type: number,
x: number,
k: number,
c: number,
) => {
return k * calculateEasingValue(type, x) + c * x;
};
-
最后,进行单位换算并返回结果:
const lengthBeat = event.endBeat - event.startBeat;
return (progress * lengthSec) / lengthBeat;
const lengthBeat = event.endBeat - event.startBeat;
return (progress * lengthSec) / lengthBeat;
const lengthBeat = event.endBeat - event.startBeat;
return (progress * lengthSec) / lengthBeat;
const lengthBeat = event.endBeat - event.startBeat;
return (progress * lengthSec) / lengthBeat;
此处的
lengthSeclengthSec与lengthBeatlengthBeat分别表示事件的持续秒数与持续拍数。
缓动截取
缓动截取的本质是对缓动函数的定义域与值域进行缩放。在 RPE 中对速度事件的缓动函数进行截取,不难发现,是基于原函数进行的操作。
我们可以直接套用现成的缓动截取处理逻辑。对于定义域的缩放,已在 calculateEasingValuecalculateEasingValue 函数中实现:
const progress = func(easingLeft + (easingRight - easingLeft) * x);
const progress = func(easingLeft + (easingRight - easingLeft) * x);
const progress = func(easingLeft + (easingRight - easingLeft) * x);
const progress = func(easingLeft + (easingRight - easingLeft) * x);
值域的缩放也就是函数的归一化,我们在前面也已经实现过了。
以下是考虑了缓动截取的逻辑实现:
const lengthBeat = event.endBeat - event.startBeat;
const easingLeft = 'easingLeft' in event ? event.easingLeft : 0;
const easingRight = 'easingRight' in event ? event.easingRight : 1;
const df0 = derivative(event.easingType, 0, easingLeft, easingRight);
const df1 = derivative(event.easingType, 1, easingLeft, easingRight);
const k = (event.end - event.start) / (df1 - df0);
const c = event.start - k * df0;
const x = (beat - event.startBeat) / (event.endBeat - event.startBeat);
const progress = integrate(event.easingType, x, k, c, easingLeft, easingRight);
return (progress * lengthSec) / lengthBeat;
const lengthBeat = event.endBeat - event.startBeat;
const easingLeft = 'easingLeft' in event ? event.easingLeft : 0;
const easingRight = 'easingRight' in event ? event.easingRight : 1;
const df0 = derivative(event.easingType, 0, easingLeft, easingRight);
const df1 = derivative(event.easingType, 1, easingLeft, easingRight);
const k = (event.end - event.start) / (df1 - df0);
const c = event.start - k * df0;
const x = (beat - event.startBeat) / (event.endBeat - event.startBeat);
const progress = integrate(event.easingType, x, k, c, easingLeft, easingRight);
return (progress * lengthSec) / lengthBeat;
const lengthBeat = event.endBeat - event.startBeat;
const easingLeft = 'easingLeft' in event ? event.easingLeft : 0;
const easingRight = 'easingRight' in event ? event.easingRight : 1;
const df0 = derivative(event.easingType, 0, easingLeft, easingRight);
const df1 = derivative(event.easingType, 1, easingLeft, easingRight);
const k = (event.end - event.start) / (df1 - df0);
const c = event.start - k * df0;
const x = (beat - event.startBeat) / (event.endBeat - event.startBeat);
const progress = integrate(event.easingType, x, k, c, easingLeft, easingRight);
return (progress * lengthSec) / lengthBeat;
const lengthBeat = event.endBeat - event.startBeat;
const easingLeft = 'easingLeft' in event ? event.easingLeft : 0;
const easingRight = 'easingRight' in event ? event.easingRight : 1;
const df0 = derivative(event.easingType, 0, easingLeft, easingRight);
const df1 = derivative(event.easingType, 1, easingLeft, easingRight);
const k = (event.end - event.start) / (df1 - df0);
const c = event.start - k * df0;
const x = (beat - event.startBeat) / (event.endBeat - event.startBeat);
const progress = integrate(event.easingType, x, k, c, easingLeft, easingRight);
return (progress * lengthSec) / lengthBeat;
对于导数值的计算,只需要将事件的截取区间直接向下传递到 calculateEasingValuecalculateEasingValue 函数中即可,其余部分与之前的处理逻辑一致:
export const derivative = (
type: number,
x: number,
easingLeft = 0,
easingRight = 1,
epsilon = 1e-12,
) => {
const leftX = Math.max(1e-16, x - epsilon);
const rightX = Math.min(1 - 1e-16, x + epsilon);
const leftY = calculateEasingValue(type, leftX, easingLeft, easingRight);
const rightY = calculateEasingValue(type, rightX, easingLeft, easingRight);
return (rightY - leftY) / (rightX - leftX);
};
export const derivative = (
type: number,
x: number,
easingLeft = 0,
easingRight = 1,
epsilon = 1e-12,
) => {
const leftX = Math.max(1e-16, x - epsilon);
const rightX = Math.min(1 - 1e-16, x + epsilon);
const leftY = calculateEasingValue(type, leftX, easingLeft, easingRight);
const rightY = calculateEasingValue(type, rightX, easingLeft, easingRight);
return (rightY - leftY) / (rightX - leftX);
};
export const derivative = (
type: number,
x: number,
easingLeft = 0,
easingRight = 1,
epsilon = 1e-12,
) => {
const leftX = Math.max(1e-16, x - epsilon);
const rightX = Math.min(1 - 1e-16, x + epsilon);
const leftY = calculateEasingValue(type, leftX, easingLeft, easingRight);
const rightY = calculateEasingValue(type, rightX, easingLeft, easingRight);
return (rightY - leftY) / (rightX - leftX);
};
export const derivative = (
type: number,
x: number,
easingLeft = 0,
easingRight = 1,
epsilon = 1e-12,
) => {
const leftX = Math.max(1e-16, x - epsilon);
const rightX = Math.min(1 - 1e-16, x + epsilon);
const leftY = calculateEasingValue(type, leftX, easingLeft, easingRight);
const rightY = calculateEasingValue(type, rightX, easingLeft, easingRight);
return (rightY - leftY) / (rightX - leftX);
};
同理,对于实际缓动函数的计算,也只需要将截取区间传递到 calculateEasingValuecalculateEasingValue 函数中即可,不需要其他改动:
export const integrate = (
type: number,
x: number,
k: number,
c: number,
easingLeft = 0,
easingRight = 1,
) => {
return k * calculateEasingValue(type, x, easingLeft, easingRight) + c * x;
};
export const integrate = (
type: number,
x: number,
k: number,
c: number,
easingLeft = 0,
easingRight = 1,
) => {
return k * calculateEasingValue(type, x, easingLeft, easingRight) + c * x;
};
export const integrate = (
type: number,
x: number,
k: number,
c: number,
easingLeft = 0,
easingRight = 1,
) => {
return k * calculateEasingValue(type, x, easingLeft, easingRight) + c * x;
};
export const integrate = (
type: number,
x: number,
k: number,
c: number,
easingLeft = 0,
easingRight = 1,
) => {
return k * calculateEasingValue(type, x, easingLeft, easingRight) + c * x;
};