解决粒子首位帧衔接问题
This commit is contained in:
1 parent
0656684a92
commit
9c2421110c
22 files changed
+2873
-1407
No files matched your search
+19
-106
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user