解决粒子首位帧衔接问题

This commit is contained in:
tianmo committed 2026-09-02 22:55:38 +08:00
1 parent 0656684a92
commit 9c2421110c
22 files changed
+2873 -1407

No files matched your search

+19 -106
View File
@@ -188,6 +188,8 @@
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { Application, Container, Graphics, Sprite, Text, Texture } from 'pixi.js'
import { ParticleEmitter, ensureEmitterConfig, type ParticleState } from '../core/particleEmitter'
import { bakeLoopFrames } from '../core/loopFrameBaker'
import { loopPlaybackRange } from '../core/loopSegmentTiming'
import { useParticleStore, type CollisionBody, type ParticleSystem, type ScenePath } from '../store/particleStore'
import { SpineRuntimeLayer } from '../spine/SpineRuntimeLayer'
import { editorHistoryState, installEditorHistory, setUndoLimit, undoLastOperation } from '../editor/editorHistory'
@@ -1171,108 +1173,6 @@ function appendStateBones(target: BonePreview[], state: ParticleState) {
})
}
function cloneParticleFrame(frame: ParticleState[]): ParticleState[] {
return frame.map((state) => ({
...state,
trailState: state.trailState
? { ...state.trailState, bones: state.trailState.bones.map((bone) => ({ ...bone })) }
: undefined,
}))
}
/**
* 纯循环段保持同一组粒子骨骼贯穿整个片段。
* 某一粒子在正常生命周期之外不删除骨骼,而是生成 alpha=0 的“假死”快照;
* 因此视觉上仍按正常时机消失,结构上却不会因骨骼增删破坏首尾衔接。
*/
function stabilizeLoopParticleBones(frames: ParticleState[][]) {
const templates = new Map<string, ParticleState>()
for (const frame of frames) {
for (const state of frame) if (!templates.has(state.boneName)) templates.set(state.boneName, cloneParticleFrame([state])[0])
}
const boneNames = [...templates.keys()].sort((a, b) => {
const ai = Number(a.replace(/^p_/, ''))
const bi = Number(b.replace(/^p_/, ''))
return Number.isFinite(ai) && Number.isFinite(bi) ? ai - bi : a.localeCompare(b)
})
for (let frameIndex = 0; frameIndex < frames.length; frameIndex++) {
const byName = new Map(frames[frameIndex].map((state) => [state.boneName, state]))
frames[frameIndex] = boneNames.map((boneName) => {
const current = byName.get(boneName)
if (current) return current
const template = cloneParticleFrame([templates.get(boneName)!])[0]
return { ...template, alpha: 0, imageVisible: false, trailState: undefined, active: true }
})
}
}
/**
* 预先生成循环持续的独立片段。
* 三种片段使用相同种子、相同固定步长和相同预热步数,因此启动末帧、纯循环首尾帧、结束首帧完全一致。
*/
function bakeLoopFrames(system: ParticleSystem, emitter: ParticleEmitter, fps: number, totalFrames: number) {
const config = system.config
const frameDuration = 1 / Math.max(1, fps)
const maxLife = config.lifeMode === 'fixed'
? Math.max(0, Number(config.lifeMin) || 0)
: Math.max(0, Number(config.lifeMin) || 0, Number(config.lifeMax) || 0)
const durationFrames = Math.max(1, Math.ceil(Number(config.loopDurationFrames) || 1))
const maxLifeFrames = Math.max(1, Math.ceil(maxLife * fps))
const warmupCycles = Math.max(1, Math.ceil(maxLifeFrames / durationFrames))
const delayFrames = Math.max(0, Math.ceil(Math.max(0, Number(config.delay) || 0) * fps))
const frames: ParticleState[][] = []
emitter.reset()
if (config.generateLoopStartAnimation) {
for (let index = 0; index < delayFrames; index++) frames.push([])
// 启动段的第一帧严格为空;随后自然预热到和纯循环段完全相同的稳定衔接状态。
frames.push([])
for (let cycle = 0; cycle < warmupCycles; cycle++) {
if (cycle > 0) emitter.restartEmissionCycle()
for (let index = 0; index < durationFrames; index++) {
emitter.update(frameDuration, 'force')
frames.push(emitter.capture())
}
}
} else {
// 预跑足够多的完整周期,让跨周期存活的粒子进入稳定分布。
for (let cycle = 0; cycle < warmupCycles; cycle++) {
if (cycle > 0) emitter.restartEmissionCycle()
for (let index = 0; index < durationFrames; index++) emitter.update(frameDuration, 'force')
}
const seam = emitter.capture()
// 纯循环和结束段在延迟期间保持循环衔接帧;延迟结束后才开始推进周期或消亡。
for (let index = 0; index < delayFrames; index++) frames.push(cloneParticleFrame(seam))
frames.push(cloneParticleFrame(seam))
if (config.generateLoopEndAnimation) {
// 结束段从衔接帧开始,停止产生新粒子,直到粒子和其拖尾全部消失。
const safetyFrames = Math.max(1, Math.ceil(maxLife * fps) + 2)
for (let index = 0; index < safetyFrames && emitter.activeCount > 0; index++) {
emitter.update(frameDuration, 'off')
frames.push(emitter.capture())
}
if (frames[frames.length - 1]?.length) frames.push([])
} else {
// 重新开始相同的发射周期,并保留跨周期粒子;末端点由真实演算产生,不再硬复制首帧。
emitter.restartEmissionCycle()
for (let index = 0; index < durationFrames; index++) {
emitter.update(frameDuration, 'force')
frames.push(emitter.capture())
}
stabilizeLoopParticleBones(frames)
}
}
const finalFrame = cloneParticleFrame(frames[frames.length - 1] || [])
if (frames.length > totalFrames) frames.length = totalFrames
while (frames.length < totalFrames) frames.push(cloneParticleFrame(finalFrame))
// 循环持续拥有自己的衔接帧规则:纯循环和结束段允许第 0 帧保留骨骼;
// 启动段则已在上方按定义生成空首帧。
system.frames = frames
emitter.apply(frames[0] || [])
}
function drawBones(bones: BonePreview[]) {
if (!boneGfx) return
const g = boneGfx
@@ -1676,7 +1576,10 @@ function loop() {
if (emitter && !(sys.config.mode === 'stream' && sys.config.streamBehavior === 'loop')) sys.frames[tl.frame] = emitter.capture()
}
tl.frame++
if (tl.frame >= tl.totalFrames) { tl.frame = 0; tl.recorded = true }
if (tl.frame >= tl.totalFrames) {
tl.frame = tl.playbackStart
tl.recorded = true
}
} else {
// 回放阶段:每经过一个固定帧时长才应用下一帧。
for (const sys of store.systems) {
@@ -1691,9 +1594,19 @@ function loop() {
}
}
tl.frame++
if (tl.frame >= tl.totalFrames) {
if (tl.loop) tl.frame = 0
else { tl.frame = tl.totalFrames - 1; tl.playing = false; break }
const activeLoopSystem = store.systems.find((system) => system.id === store.activeId)
const activeLoopRange = activeLoopSystem?.config.mode === 'stream' && activeLoopSystem.config.streamBehavior === 'loop'
? loopPlaybackRange(activeLoopSystem.config, fps)
: null
// 烘焙数据保留“末帧 = 首帧”以便导出闭环。预览精确循环段时,末帧只作为
// 数学边界,不再与下一轮首帧连续显示两次,避免衔接处停顿一帧。
const closesActiveLoop = tl.loop
&& activeLoopRange?.start === tl.playbackStart
&& activeLoopRange.end === tl.playbackEnd
const playbackLastFrame = closesActiveLoop ? tl.playbackEnd - 1 : tl.playbackEnd
if (tl.frame > playbackLastFrame) {
if (tl.loop) tl.frame = tl.playbackStart
else { tl.frame = tl.playbackEnd; tl.playing = false; break }
}
}
}