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