修正时间轴末帧,导出补阶跃与循环段边界关键帧

- 时间轴:末帧按时长实际覆盖的整数帧计算,修掉 Spine 时长小数舍入多出的一帧,B 端与回放自动停在最后一帧
- 导出:粒子死亡、骨骼池复用、拖尾假死复活处统一写 curve: stepped 硬切,避免死区插值出滑动轨迹
- 导出:循环持续在开始/循环/结束段边界(如 30f/110f)对所有动画通道补关键帧,便于在 Spine 中直接拆分
- 精简器支持阶跃段,阶跃区间内不再补出过渡关键帧
- 补充 test:spine-json 回归用例:骨骼池复用阶跃边界、循环段边界
This commit is contained in:
tianmo committed 2026-10-09 17:05:53 +08:00
1 parent f249c019bc
commit d6232c9d66
9 files changed
+290 -82

No files matched your search

+90 -25
View File
@@ -1,11 +1,11 @@
import type { EmitterConfig, ParticleImageResource, ParticleState, TrailImageResource } from '../core/particleEmitter'
import type { EmitterConfig, ParticleImageResource, ParticleState, TrailImageResource, TrailState } from '../core/particleEmitter'
import type { ParticleSystem, TimelineState } from '../store/particleStore'
import type { SpineExportSettings } from './spineExportSettings'
import type { SpineExportResult, SpineExportWarning, SpineJsonDocument, SpineJsonMap } from './spineJsonTypes'
import { downgradeSpineJsonTo38 } from './spine38JsonExporter'
import { reduceBezierSamples, reduceNumericSamples, roundSpine, unwrapDegrees, type NumericSample } from './spineKeyframeReducer'
import { buildWeightedTrailMesh } from './spineMeshBuilder'
import { loopSegmentTiming } from '../core/loopSegmentTiming'
import { loopPlaybackRange, loopSegmentTiming } from '../core/loopSegmentTiming'
type Track = { key: string; name: string; states: Array<ParticleState | null> }
type ExportInput = { systems: ParticleSystem[]; timeline: TimelineState; settings: SpineExportSettings }
@@ -57,6 +57,20 @@ function particleDurationFrames(system: ParticleSystem, fps: number) {
return Math.max(1, Math.ceil(end * fps) + 1)
}
/**
* 循环持续模式下的段边界(开始→循环、循环→结束)在导出时间轴上的帧号。
* 这些位置必须对**所有动画通道**补关键帧,才能在 Spine 里直接拆成
* 开始 / 循环 / 结束三段动画,而不必手动 K 帧。
* 返回的是目标导出 fps 下的帧号(源帧号按比例换算)。
*/
function loopSegmentBoundaryFrames(system: ParticleSystem, sourceFps: number, targetFps: number) {
const config = system.config
if (config.mode !== 'stream' || config.streamBehavior !== 'loop') return new Set<number>()
const range = loopPlaybackRange(config, sourceFps)
const scale = Math.max(1, targetFps) / Math.max(1, sourceFps)
return new Set<number>([Math.round(range.start * scale), Math.round(range.end * scale)])
}
function stateIdentity(state: ParticleState, bonePool: boolean) {
return bonePool ? state.boneName : `${state.spawnId ?? 'legacy'}_${state.boneName}`
}
@@ -117,17 +131,23 @@ function resolvedTimelineCurve(mode: '跟随导出' | '线性' | '贝塞尔' | u
return mode === '贝塞尔' ? 'bezier' : 'linear'
}
function numericTimeline(samples: NumericSample[], fps: number, names: string[], tolerances: number[], settings: SpineExportSettings, forcedFrames: Set<number> = new Set(), curveMode: TimelineCurveMode = settings.keyframeCurve) {
function numericTimeline(samples: NumericSample[], fps: number, names: string[], tolerances: number[], settings: SpineExportSettings, forcedFrames: Set<number> = new Set(), steppedFrames: Set<number> = new Set(), curveMode: TimelineCurveMode = settings.keyframeCurve) {
if (!samples.length) return []
// 阶跃帧必须保留,且所在段按“保持起始值”拟合,避免被补出过渡关键帧。
const forced = new Set(forcedFrames)
for (const frame of steppedFrames) forced.add(frame)
const fitted = curveMode === 'bezier' && settings.spineVersion !== '3.8'
? reduceBezierSamples(samples, tolerances, forcedFrames)
: { samples: reduceNumericSamples(samples, tolerances, forcedFrames), controls: [] }
? reduceBezierSamples(samples, tolerances, forced, steppedFrames)
: { samples: reduceNumericSamples(samples, tolerances, forced, steppedFrames), controls: [] }
const keys = fitted.samples.map((sample) => {
const key: SpineJsonMap = { time: time(sample.frame, fps) }
names.forEach((name, index) => { key[name] = roundSpine(sample.values[index]) })
// Spine 用字符串 "stepped" 表示该关键帧保持到下一帧再硬切(4.2/4.3 运行时均支持)。
if (steppedFrames.has(sample.frame)) key.curve = 'stepped'
return key
})
for (let index = 0; index < fitted.controls.length; index++) {
if (keys[index].curve === 'stepped') continue
const startTime = Number(keys[index].time) || 0
const endTime = Number(keys[index + 1].time) || 0
const curve: number[] = []
@@ -154,6 +174,32 @@ function stateBoundaryFrames(states: Array<ParticleState | null>) {
return forced
}
/**
* 一段连续粒子(同一 spawnId)的最后一帧。
* 该帧之后是死亡(进入隐藏/假死)或骨骼池把同一根骨骼复用给新粒子,
* 必须写成阶跃关键帧硬切,不能让变换在死区/复用点之间插值出滑动轨迹。
*/
function particleSteppedFrames(states: Array<ParticleState | null>) {
const stepped = new Set<number>()
for (let frame = 0; frame < states.length; frame++) {
const current = states[frame]
if (!current?.active) continue
const next = states[frame + 1]
if (!next?.active || next.spawnId !== current.spawnId) stepped.add(frame)
}
return stepped
}
/** 一段连续拖尾的最后一帧;之后进入“透明假死”,同样需要用阶跃硬切。 */
function trailSteppedFrames(states: Array<TrailState | undefined>) {
const stepped = new Set<number>()
for (let frame = 0; frame < states.length; frame++) {
if (!states[frame]) continue
if (!states[frame + 1]) stepped.add(frame)
}
return stepped
}
function optionalStateBoundaryFrames<T>(states: Array<T | undefined>) {
const forced = new Set<number>()
for (let frame = 0; frame < states.length; frame++) {
@@ -180,10 +226,11 @@ function trailShapeAt(resource: TrailImageResource, t: number) {
return Math.max(0, points[points.length - 1].y)
}
function discreteTimeline<T>(values: T[], fps: number, make: (value: T, frame: number) => SpineJsonMap) {
function discreteTimeline<T>(values: T[], fps: number, make: (value: T, frame: number) => SpineJsonMap, forcedFrames: Set<number> = new Set()) {
const keys: SpineJsonMap[] = []
let previous: T | symbol = Symbol()
values.forEach((value, frame) => { if (value !== previous) { keys.push(make(value, frame)); previous = value } })
// 强制帧即使值没变化也补一个关键帧(循环段边界需要在每个通道上都有键)。
values.forEach((value, frame) => { if (value !== previous || forcedFrames.has(frame)) { keys.push(make(value, frame)); previous = value } })
return keys
}
@@ -202,7 +249,7 @@ function resourceAttachment(resource: ParticleImageResource, frameIndex: number,
}
}
function rgbaTimeline(colors: number[][], attachments: Array<string | null>, fps: number, settings: SpineExportSettings, curveMode: TimelineCurveMode = settings.keyframeCurve) {
function rgbaTimeline(colors: number[][], attachments: Array<string | null>, fps: number, settings: SpineExportSettings, curveMode: TimelineCurveMode = settings.keyframeCurve, steppedFrames: Set<number> = new Set(), boundaryFrames: Set<number> = new Set()) {
if (!colors.length) return []
const forcedFrames = new Set<number>()
for (let frame = 1; frame < attachments.length; frame++) {
@@ -216,16 +263,25 @@ function rgbaTimeline(colors: number[][], attachments: Array<string | null>, fps
forcedFrames.add(frame - 1)
}
}
// 阶跃帧必须保留,且所在段按“保持起始值”拟合(颜色/透明度不在粒子池复用时被平滑)。
for (const frame of steppedFrames) forcedFrames.add(frame)
// 循环段边界:即使颜色/透明度连续也要在边界打键,方便在 Spine 里拆分动画。
for (const frame of boundaryFrames) forcedFrames.add(frame)
// 透明度使用未量化的浮点值参与精简,线性渐变能直接收敛到首尾关键帧;
// RGB 保持单通道 1/255,透明度使用 0.01 的视觉容差。
const samples: NumericSample[] = colors.map((values, frame) => ({ frame, values }))
const rgbTolerance = 1 / 255
const fitted = curveMode === 'bezier' && settings.spineVersion !== '3.8'
? reduceBezierSamples(samples, [rgbTolerance, rgbTolerance, rgbTolerance, 0.01], forcedFrames)
: { samples: reduceNumericSamples(samples, [rgbTolerance, rgbTolerance, rgbTolerance, 0.01], forcedFrames), controls: [] }
const keys = fitted.samples.map((sample) => ({ time: time(sample.frame, fps), color: `${byte(sample.values[0])}${byte(sample.values[1])}${byte(sample.values[2])}${byte(sample.values[3])}` } as SpineJsonMap))
? reduceBezierSamples(samples, [rgbTolerance, rgbTolerance, rgbTolerance, 0.01], forcedFrames, steppedFrames)
: { samples: reduceNumericSamples(samples, [rgbTolerance, rgbTolerance, rgbTolerance, 0.01], forcedFrames, steppedFrames), controls: [] }
const keys = fitted.samples.map((sample) => {
const key = { time: time(sample.frame, fps), color: `${byte(sample.values[0])}${byte(sample.values[1])}${byte(sample.values[2])}${byte(sample.values[3])}` } as SpineJsonMap
if (steppedFrames.has(sample.frame)) key.curve = 'stepped'
return key
})
if (fitted.controls.length) {
for (let index = 0; index < fitted.controls.length; index++) {
if (keys[index].curve === 'stepped') continue
const t0 = time(fitted.samples[index].frame, fps), t1 = time(fitted.samples[index + 1].frame, fps)
const curve: number[] = []
for (let channel = 0; channel < 4; channel++) {
@@ -238,13 +294,13 @@ function rgbaTimeline(colors: number[][], attachments: Array<string | null>, fps
return keys
}
function addSlotAnimation(animation: SpineJsonMap, slotName: string, attachments: Array<string | null>, colors: number[][], fps: number, settings: SpineExportSettings, curveMode: TimelineCurveMode = settings.keyframeCurve) {
function addSlotAnimation(animation: SpineJsonMap, slotName: string, attachments: Array<string | null>, colors: number[][], fps: number, settings: SpineExportSettings, curveMode: TimelineCurveMode = settings.keyframeCurve, steppedFrames: Set<number> = new Set(), boundaryFrames: Set<number> = new Set()) {
const slot = animation.slots[slotName] ||= {}
// Spine 3.8 编辑器要求附件时间轴的每一帧都存在 name;隐藏附件必须显式写 null,
// 省略字段会在导入时触发 “Named value not found: name”。4.x 同样接受该标准写法。
const attachment = discreteTimeline(attachments, fps, (name, frame) => ({ time: time(frame, fps), name: name ?? null }))
const attachment = discreteTimeline(attachments, fps, (name, frame) => ({ time: time(frame, fps), name: name ?? null }), boundaryFrames)
if (attachment.length) slot.attachment = attachment
const rgba = rgbaTimeline(colors, attachments, fps, settings, curveMode)
const rgba = rgbaTimeline(colors, attachments, fps, settings, curveMode, steppedFrames, boundaryFrames)
if (rgba.length) slot.rgba = rgba
}
@@ -265,7 +321,7 @@ export function buildSpineJson(input: ExportInput): SpineExportResult {
const skin = document.skins[0].attachments as SpineJsonMap
const usedNames = new Set<string>(['root'])
const animationNames = [...new Set([...(input.timeline.animations.length ? input.timeline.animations : ['animation']), ...visibleSystems.map((system) => system.animation || 'animation')])]
const animationSystems = new Map<string, Array<{ system: ParticleSystem; tracks: Track[]; targetFrames: number }>>()
const animationSystems = new Map<string, Array<{ system: ParticleSystem; tracks: Track[]; targetFrames: number; boundaryFrames: Set<number> }>>()
for (const system of visibleSystems) {
const sourceCount = Math.min(system.frames?.length || 0, particleDurationFrames(system, sourceFps))
@@ -276,8 +332,10 @@ export function buildSpineJson(input: ExportInput): SpineExportResult {
const tracks = collectTracks(frames, settings.bonePool, usedNames)
const duration = Math.max(0, (sourceCount - 1) / sourceFps)
const targetFrames = Math.max(1, Math.ceil(duration * fps) + 1)
// 循环持续:开始在/循环/结束的段边界帧,所有通道都要补关键帧。
const boundaryFrames = loopSegmentBoundaryFrames(system, sourceFps, fps)
const group = animationSystems.get(system.animation) || []
group.push({ system, tracks, targetFrames })
group.push({ system, tracks, targetFrames, boundaryFrames })
animationSystems.set(system.animation, group)
for (const track of tracks) {
@@ -330,7 +388,7 @@ export function buildSpineJson(input: ExportInput): SpineExportResult {
const entries = animationSystems.get(animationName) || []
const animation: SpineJsonMap = { bones: {}, slots: {} }
let hasContent = false
for (const { system, tracks, targetFrames } of entries) {
for (const { system, tracks, targetFrames, boundaryFrames } of entries) {
for (const track of tracks) {
const states = Array.from({ length: targetFrames }, (_, frame) => sourceStateAt(track, frame, sourceFps, fps))
const active = states.map((state) => !!state?.active)
@@ -344,10 +402,14 @@ export function buildSpineJson(input: ExportInput): SpineExportResult {
const rotationValues = unwrapDegrees(rotations)
const rotationSamples: NumericSample[] = positions.map((sample, index) => ({ frame: sample.frame, values: [rotationValues[index] ?? 0] }))
const motionBoundaries = stateBoundaryFrames(states)
// 循环段边界也要在变换通道上补键(即使该处运动是连续的)。
for (const frame of boundaryFrames) motionBoundaries.add(frame)
// 死亡/骨骼池复用点写成阶跃硬切,避免变换在死区里插值出滑动。
const stepped = particleSteppedFrames(states)
const boneTimeline: SpineJsonMap = {}
const translate = numericTimeline(positions, fps, ['x', 'y'], [PARTICLE_POSITION_TOLERANCE, PARTICLE_POSITION_TOLERANCE], settings, motionBoundaries)
const rotate = numericTimeline(rotationSamples, fps, ['value'], [ROTATION_TOLERANCE], settings, motionBoundaries)
const scale = numericTimeline(scales, fps, ['x', 'y'], [SCALE_TOLERANCE, SCALE_TOLERANCE], settings, motionBoundaries)
const translate = numericTimeline(positions, fps, ['x', 'y'], [PARTICLE_POSITION_TOLERANCE, PARTICLE_POSITION_TOLERANCE], settings, motionBoundaries, stepped)
const rotate = numericTimeline(rotationSamples, fps, ['value'], [ROTATION_TOLERANCE], settings, motionBoundaries, stepped)
const scale = numericTimeline(scales, fps, ['x', 'y'], [SCALE_TOLERANCE, SCALE_TOLERANCE], settings, motionBoundaries, stepped)
if (translate.length) boneTimeline.translate = translate
if (rotate.length) boneTimeline.rotate = rotate
if (scale.length) boneTimeline.scale = scale
@@ -373,7 +435,7 @@ export function buildSpineJson(input: ExportInput): SpineExportResult {
const rgbaCurveMode = requestedModes.length
? (requestedModes.includes('bezier') ? 'bezier' : 'linear')
: resolvedTimelineCurve(system.config.alphaMode === 'curve' ? system.config.curveMode : undefined, settings)
addSlotAnimation(animation, `${track.name}_image_${blend}`, attachments, colors, fps, settings, rgbaCurveMode)
addSlotAnimation(animation, `${track.name}_image_${blend}`, attachments, colors, fps, settings, rgbaCurveMode, stepped, boundaryFrames)
if (attachments.some(Boolean)) hasContent = true
}
@@ -383,6 +445,8 @@ export function buildSpineJson(input: ExportInput): SpineExportResult {
const firstTrailState = trailStates.find((trail) => !!trail)
if (!firstTrailState) continue
const trailBoundaries = optionalStateBoundaryFrames(trailStates)
// 拖尾死亡后进入“透明假死”,保持段必须是阶跃,复活时硬切。
const trailStepped = trailSteppedFrames(trailStates)
const referenceWidth = resource.widthSizeMode === 'random'
? Math.max(1, Number(resource.widthMin) || 0, Number(resource.widthMax) || 0)
: Math.max(0.001, Number(resource.width) || 100)
@@ -396,6 +460,7 @@ export function buildSpineJson(input: ExportInput): SpineExportResult {
for (let boneIndex = 0; boneIndex < count; boneIndex++) {
const samplesTranslate: NumericSample[] = [], samplesRotate: NumericSample[] = [], samplesScale: NumericSample[] = []
const boneBoundaries = new Set(trailBoundaries)
for (const frame of boundaryFrames) boneBoundaries.add(frame)
const shapeScale = trailShapeAt(resource, boneIndex / Math.max(1, count - 1))
rigStates.forEach((trail, frame) => {
const bone = trail?.bones[Math.min(trail.bones.length - 1, boneIndex)]
@@ -413,9 +478,9 @@ export function buildSpineJson(input: ExportInput): SpineExportResult {
})
const name = `${track.name}_trail_${resource.id}_${boneIndex}`
const timeline: SpineJsonMap = {}
const translate = numericTimeline(samplesTranslate, fps, ['x', 'y'], [TRAIL_POSITION_TOLERANCE, TRAIL_POSITION_TOLERANCE], settings, boneBoundaries)
const rotate = numericTimeline(samplesRotate, fps, ['value'], [ROTATION_TOLERANCE], settings, boneBoundaries)
const scale = numericTimeline(samplesScale, fps, ['x', 'y'], [SCALE_TOLERANCE, SCALE_TOLERANCE], settings, boneBoundaries)
const translate = numericTimeline(samplesTranslate, fps, ['x', 'y'], [TRAIL_POSITION_TOLERANCE, TRAIL_POSITION_TOLERANCE], settings, boneBoundaries, trailStepped)
const rotate = numericTimeline(samplesRotate, fps, ['value'], [ROTATION_TOLERANCE], settings, boneBoundaries, trailStepped)
const scale = numericTimeline(samplesScale, fps, ['x', 'y'], [SCALE_TOLERANCE, SCALE_TOLERANCE], settings, boneBoundaries, trailStepped)
if (translate.length) timeline.translate = translate
if (rotate.length) timeline.rotate = rotate
if (scale.length) timeline.scale = scale
@@ -431,7 +496,7 @@ export function buildSpineJson(input: ExportInput): SpineExportResult {
// 整组拖尾骨骼同生同死,因此可由唯一插槽统一透明假死。
return rgbaValues(lastTrailColor, trail ? trail.alpha : 0)
})
addSlotAnimation(animation, `${track.name}_trail_${resource.id}`, attachments, colors, fps, settings)
addSlotAnimation(animation, `${track.name}_trail_${resource.id}`, attachments, colors, fps, settings, settings.keyframeCurve, trailStepped, boundaryFrames)
hasContent = true
}
}
+38 -8
View File
@@ -13,7 +13,19 @@ export interface ReducedBezierSamples {
controls: BezierSegmentControls[]
}
function interpolationError(sample: NumericSample, start: NumericSample, end: NumericSample, tolerances: number[]) {
/**
* 段内某采样点的拟合误差。
* `stepped` 为真表示该段起始关键帧是阶跃帧:这一段的值保持起始值不变,
* 因此误差按“与起始值之差”计算,段内不会因为要跟随线性/贝塞尔插值而被补关键帧。
*/
function interpolationError(sample: NumericSample, start: NumericSample, end: NumericSample, tolerances: number[], stepped = false) {
if (stepped) {
let error = 0
for (let i = 0; i < sample.values.length; i++) {
error = Math.max(error, Math.abs(sample.values[i] - start.values[i]) / Math.max(1e-9, tolerances[i] ?? tolerances[0] ?? 1e-3))
}
return error
}
const span = end.frame - start.frame
const amount = span <= 0 ? 0 : (sample.frame - start.frame) / span
let error = 0
@@ -24,11 +36,15 @@ function interpolationError(sample: NumericSample, start: NumericSample, end: Nu
return error
}
/** 保留误差最大的点并递归拆分,确保删除关键帧后仍在给定误差内。 */
export function reduceNumericSamples(samples: NumericSample[], tolerances: number[], forcedFrames: Set<number> = new Set()) {
/**
* 保留误差最大的点并递归拆分,确保删除关键帧后仍在给定误差内。
* `steppedFrames` 中的帧为阶跃关键帧(值保持到下一个关键帧),段内不再补点。
*/
export function reduceNumericSamples(samples: NumericSample[], tolerances: number[], forcedFrames: Set<number> = new Set(), steppedFrames: Set<number> = new Set()) {
if (samples.length <= 2) return samples.slice()
const retained = new Set<number>([0, samples.length - 1])
for (let i = 1; i < samples.length - 1; i++) if (forcedFrames.has(samples[i].frame)) retained.add(i)
for (let i = 0; i < samples.length; i++) if (steppedFrames.has(samples[i].frame)) retained.add(i)
const anchors = () => [...retained].sort((a, b) => a - b)
let changed = true
while (changed) {
@@ -37,10 +53,11 @@ export function reduceNumericSamples(samples: NumericSample[], tolerances: numbe
for (let segment = 0; segment < ordered.length - 1; segment++) {
const startIndex = ordered[segment]
const endIndex = ordered[segment + 1]
const stepped = steppedFrames.has(samples[startIndex].frame)
let worstIndex = -1
let worstError = 1
for (let i = startIndex + 1; i < endIndex; i++) {
const error = interpolationError(samples[i], samples[startIndex], samples[endIndex], tolerances)
const error = interpolationError(samples[i], samples[startIndex], samples[endIndex], tolerances, stepped)
if (error > worstError) { worstError = error; worstIndex = i }
}
if (worstIndex >= 0) { retained.add(worstIndex); changed = true }
@@ -90,7 +107,14 @@ function cubicAt(start: number, control1: number, control2: number, end: number,
+ amount * amount * amount * end
}
function bezierInterpolationError(sample: NumericSample, start: NumericSample, end: NumericSample, controls: BezierSegmentControls, tolerances: number[]) {
function bezierInterpolationError(sample: NumericSample, start: NumericSample, end: NumericSample, controls: BezierSegmentControls, tolerances: number[], stepped = false) {
if (stepped) {
let error = 0
for (let dimension = 0; dimension < sample.values.length; dimension++) {
error = Math.max(error, Math.abs(sample.values[dimension] - start.values[dimension]) / Math.max(1e-9, tolerances[dimension] ?? tolerances[0] ?? 1e-3))
}
return error
}
const span = end.frame - start.frame
const amount = span <= 0 ? 0 : (sample.frame - start.frame) / span
let error = 0
@@ -102,11 +126,16 @@ function bezierInterpolationError(sample: NumericSample, start: NumericSample, e
return error
}
/** 使用三次贝塞尔拟合连续采样,并以原始逐帧数据验证每一段误差。 */
export function reduceBezierSamples(samples: NumericSample[], tolerances: number[], forcedFrames: Set<number> = new Set()): ReducedBezierSamples {
/**
* 使用三次贝塞尔拟合连续采样,并以原始逐帧数据验证每一段误差。
* `steppedFrames` 中的帧为阶跃关键帧,其所在的段按“保持起始值”参与误差计算,
* 因此段内不会被补出过渡关键帧。
*/
export function reduceBezierSamples(samples: NumericSample[], tolerances: number[], forcedFrames: Set<number> = new Set(), steppedFrames: Set<number> = new Set()): ReducedBezierSamples {
if (samples.length <= 1) return { samples: samples.slice(), controls: [] }
const retained = new Set<number>([0, samples.length - 1])
for (let index = 1; index < samples.length - 1; index++) if (forcedFrames.has(samples[index].frame)) retained.add(index)
for (let index = 0; index < samples.length; index++) if (steppedFrames.has(samples[index].frame)) retained.add(index)
const anchors = () => [...retained].sort((a, b) => a - b)
let changed = true
while (changed) {
@@ -115,10 +144,11 @@ export function reduceBezierSamples(samples: NumericSample[], tolerances: number
for (let segment = 0; segment < ordered.length - 1; segment++) {
const start = ordered[segment], end = ordered[segment + 1]
if (end - start <= 1) continue
const stepped = steppedFrames.has(samples[start].frame)
const controls = bezierControls(samples, start, end)
let worstIndex = -1, worstError = 1
for (let index = start + 1; index < end; index++) {
const error = bezierInterpolationError(samples[index], samples[start], samples[end], controls, tolerances)
const error = bezierInterpolationError(samples[index], samples[start], samples[end], controls, tolerances, stepped)
if (error > worstError) { worstError = error; worstIndex = index }
}
if (worstIndex >= 0) { retained.add(worstIndex); changed = true }
+5 -2
View File
@@ -5,7 +5,7 @@ import { releaseSpineAsset, retainSpineAsset } from '../spine/spineAssetRegistry
import { cloneEditorValue } from '../editor/editorClone'
import { defaultSpineExportSettings, type SpineExportSettings } from '../export/spineExportSettings'
import { loopSegmentTiming } from '../core/loopSegmentTiming'
import { normalizePlaybackRange, resizePlaybackRange } from '../timeline/playbackRange'
import { lastFrameOfDuration, normalizePlaybackRange, resizePlaybackRange } from '../timeline/playbackRange'
export interface ParticleSystem {
id: number
@@ -519,8 +519,11 @@ export const useParticleStore = defineStore('particle', {
if (end > maxSec) maxSec = end
}
// totalFrames 表示快照数量;0 秒和末端点都可选,因此比末端帧编号多 1。
// 末帧编号按“时长覆盖到的最后一个整数帧”计算,不能直接用 ceil:
// Spine 的小数舍入会让 80 帧的时长变成 80.01 帧,从而凭空多出第 81 帧,
// 回放时表现为末帧之后多停一帧。
const previousFrames = this.timeline.totalFrames
const frames = Math.max(2, Math.ceil(maxSec * fps) + 1)
const frames = Math.max(2, lastFrameOfDuration(maxSec, fps) + 1)
this.timeline.totalFrames = frames
const range = resizePlaybackRange(
{ start: this.timeline.playbackStart, end: this.timeline.playbackEnd },
+22
View File
@@ -1,5 +1,27 @@
export type PlaybackRange = { start: number; end: number }
/**
* 动画时长允许的舍入误差(秒)。
* Spine 等来源把 80 帧在 30fps 下的时长写成 2.667(保留小数),
* 乘回 fps 会得到 80.01 帧。误差必须小于一帧,仅用于吸收这种舍入。
*/
const DURATION_ROUNDING_TOLERANCE_SECONDS = 1e-3
/**
* 把一段时长换算成时间轴上的“末帧索引”。
*
* 末帧索引 = 该时长实际覆盖到的最后一个整数帧,因此:
* 80 帧的时长 → 末帧 80(而不是 81)。若直接对秒数×fps 取 ceil,
* Spine 的小数舍入会让 80 帧被算成 81 帧,回放时末帧后多停一帧。
* 这里改用向下取整,并对接近整数帧的舍入误差做补偿。
*/
export function lastFrameOfDuration(seconds: number, fps: number) {
const safeFps = Math.max(1, Number(fps) || 30)
const durationSeconds = Math.max(0, Number(seconds) || 0)
const frames = durationSeconds * safeFps + DURATION_ROUNDING_TOLERANCE_SECONDS * safeFps
return Math.max(0, Math.floor(frames))
}
export function normalizePlaybackRange(start: unknown, end: unknown, totalFrames: number): PlaybackRange {
const last = Math.max(1, Math.floor(totalFrames) - 1)
const safeStart = Math.min(last - 1, Math.max(0, Math.floor(Number(start) || 0)))