Files
SpineParticlesWeb/src/views/Stage.vue
T
2026-11-09 18:40:13 +08:00

772 lines
35 KiB
Vue

<template>
<div class="stage-wrap">
<div ref="holder" class="stage-holder" tabindex="0" @wheel.prevent="onWheelEvent" @pointerdown="onPanDown" @dblclick.prevent="onStageDoubleClick"></div>
<img
class="stage-watermark"
:class="{ 'watermark-left-panel': !store.settings.parameterPanelOnRight }"
src="/images/yeomai-watermark.png"
alt=""
aria-hidden="true"
/>
<button
type="button"
class="stage-home-btn"
:class="{ 'home-right': !store.settings.parameterPanelOnRight }"
:title="$t('welcome.backToTools')"
:aria-label="$t('welcome.backToTools')"
data-matomo-category="页面导航"
data-matomo-event="editor.open-welcome"
@click="returnToWelcome"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="m3.5 10.5 8.5-7 8.5 7V21h-6v-6h-5v6h-6z" />
</svg>
</button>
<StageToolbar
:ctx="stageContext"
:active-tool="activeTool"
:hud="info"
:show-settings="showSettings"
:show-help="showHelp"
@update:active-tool="activeTool = $event"
@update:show-settings="showSettings = $event"
@update:show-help="showHelp = $event"
@clear-background="clearBackground"
@background-file="onBackgroundFileChange"
/>
<StageSettingsPanel :show="showSettings" @close="showSettings = false" />
<HelpDialog :show="showHelp" @close="showHelp = false" />
</div>
</template>
<script setup lang="ts">
import { type BonePreview } from './stage/stageTypes'
import { drawAxis } from './stage/axisGuides'
import { drawOrigin, drawShapeRange, drawForceField, drawVortex, drawAttraction } from './stage/emitterGuides'
import { drawBoneFollowTargets, drawGizmo, drawCollisionBodies, drawPaths } from './stage/sceneGuides'
import { appendStateBones, drawBones } from './stage/boneGuides'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { Application, Container, Graphics, Texture, TilingSprite, type Text } from 'pixi.js'
import { ParticleEmitter, ensureEmitterConfig, type ParticleState } from '../core/particleEmitter'
import { bakeLoopFrames } from '../core/loopFrameBaker'
import { loopPlaybackRange } from '../core/loopSegmentTiming'
import { collectSubEmitterIds } from '../core/subEmitterIds'
import { useParticleStore, type CollisionBody, type ParticleSystem, type UiTheme } from '../store/particleStore'
import { SpineRuntimeLayer } from '../spine/SpineRuntimeLayer'
import { editorHistoryState, installEditorHistory, undoLastOperation } from '../editor/editorHistory'
import { useEditorShortcuts } from '../editor/useEditorShortcuts'
import { PROJECT_SAVE_EVENT } from '../editor/projectLibrary'
import { translate } from '../i18n'
import HelpDialog from './HelpDialog.vue'
import StageToolbar from './StageToolbar.vue'
import StageSettingsPanel from './StageSettingsPanel.vue'
import { onWheel, panView, type StageContext } from './stage/viewControls'
import { BackgroundLayer } from './stage/backgroundLayer'
import { PathEditing } from './stage/pathEditing'
import { ObjectInteractions } from './stage/objectInteractions'
import { EmitterSync, buildPreviewColliders, sampleSpawnPathForEmitter, worldPointToEmitterLocal } from './stage/emitterSync'
import { loadDefaultTextures, attachDefaultTextures } from './stage/textureLoading'
import { sampleScenePath } from '../core/scenePathSampler'
import { followCurveValue } from './stage/stageGeometry'
const store = useParticleStore()
const emit = defineEmits<{ (e: 'home'): void }>()
const holder = ref<HTMLElement | null>(null)
const info = ref<{ systems: number; particles: number } | null>(null)
const showSettings = ref(false)
const showHelp = ref(false)
// 根变换 gizmo 工具:'' | 'translate' | 'rotate' | 'scale'
const activeTool = ref<'' | 'translate' | 'rotate' | 'scale'>('')
const canvasFocused = ref(false)
if (!(store.settings.undoLimit > 0)) store.settings.undoLimit = 20
if (store.settings.parameterPanelOnRight == null) store.settings.parameterPanelOnRight = true
if (!Number.isFinite(store.settings.backgroundX)) store.settings.backgroundX = 0
if (!Number.isFinite(store.settings.backgroundY)) store.settings.backgroundY = 0
if (!Number.isFinite(store.settings.backgroundOpacity)) store.settings.backgroundOpacity = 1
if (store.settings.backgroundImageName == null) store.settings.backgroundImageName = ''
if (store.settings.backgroundImageData == null) store.settings.backgroundImageData = ''
if (store.settings.showEmitter == null) store.settings.showEmitter = true
if (store.settings.showBones == null) store.settings.showBones = false
if (!(store.settings.bonePointDrawSize > 0)) store.settings.bonePointDrawSize = 1
if (!(store.settings.boneAxisDrawSize > 0)) store.settings.boneAxisDrawSize = 2
if (store.settings.showSkinMesh == null) store.settings.showSkinMesh = false
if (store.settings.showGrid == null) store.settings.showGrid = true
if (!['deep-blue', 'gray-white', 'light', 'dark'].includes(store.settings.uiTheme)) store.settings.uiTheme = 'deep-blue'
if (store.settings.canvasColorMode !== 'custom' && store.settings.canvasColorMode !== 'theme') store.settings.canvasColorMode = 'theme'
if (!/^#[0-9a-f]{6}$/i.test(store.settings.canvasBackgroundColor || '')) store.settings.canvasBackgroundColor = '#0e1219'
if (!/^#[0-9a-f]{6}$/i.test(store.settings.canvasGridColor || '')) store.settings.canvasGridColor = '#141a26'
const CANVAS_THEMES: Record<UiTheme, { background: number; grid: number }> = {
'deep-blue': { background: 0x0e1219, grid: 0x141a26 },
'gray-white': { background: 0x4b4d51, grid: 0x55585d },
light: { background: 0xdfe4e8, grid: 0xccd4da },
dark: { background: 0x202326, grid: 0x282c2f },
}
function canvasPalette() {
if (store.settings.canvasColorMode === 'custom') {
return {
background: Number.parseInt(store.settings.canvasBackgroundColor.slice(1), 16),
grid: Number.parseInt(store.settings.canvasGridColor.slice(1), 16),
}
}
return CANVAS_THEMES[store.settings.uiTheme]
}
const colorNumberToHex = (color: number) => `#${color.toString(16).padStart(6, '0')}`
let previousCanvasColorMode = store.settings.canvasColorMode
function updateCanvasTheme() {
if (store.settings.canvasColorMode === 'custom' && previousCanvasColorMode === 'theme') {
const themeColors = CANVAS_THEMES[store.settings.uiTheme]
store.settings.canvasBackgroundColor = colorNumberToHex(themeColors.background)
store.settings.canvasGridColor = colorNumberToHex(themeColors.grid)
}
previousCanvasColorMode = store.settings.canvasColorMode
applyCanvasTheme()
}
let app: Application | null = null
let stageResizeObserver: ResizeObserver | null = null
let world: Container | null = null
let canvasGrid: TilingSprite | null = null
let originGfx: Graphics | null = null
let boneFollowTargetGfx: Graphics | null = null
let boneGfx: Graphics | null = null
let shapeGfx: Graphics | null = null
let forceFieldGfx: Graphics | null = null
let vortexGfx: Graphics | null = null
let attractionGfx: Graphics | null = null
let collisionGfx: Graphics | null = null
let pathGfx: Graphics | null = null
let gizmoGfx: Graphics | null = null
let axisGfx: Graphics | null = null
let axisLabelLayer: Container | null = null // 文字图层,不随 world 缩放,避免放大变糊
let spineRuntime: SpineRuntimeLayer | null = null
let axisLabels: Text[] = []
let lastT = 0
let timelineAccumulator = 0
let timelineDisplayFrame = 0
let raf = 0
let dotTex: Texture<any> | null = null
let trailTex: Texture<any> | null = null
const background = new BackgroundLayer()
const emitterSync = new EmitterSync()
const stageContext: StageContext = {
store,
holder,
app: () => app,
}
const pathEditing = new PathEditing(stageContext)
const interactions = new ObjectInteractions(stageContext, () => activeTool.value)
let previewColliders: CollisionBody[] = []
const emitterPreviewTransform = new Map<number, { x: number; y: number; rotation: number }>()
function createLayers() {
// 棋盘格必须在 WebGL 底部,粒子混合模式才能读取它。
const checker = document.createElement('canvas')
checker.width = checker.height = 28
const checkerContext = checker.getContext('2d')!
checkerContext.fillStyle = '#fff'
checkerContext.fillRect(14, 0, 14, 14)
checkerContext.fillRect(0, 14, 14, 14)
canvasGrid = new TilingSprite(Texture.from(checker), app!.screen.width, app!.screen.height)
canvasGrid.tint = canvasPalette().grid
canvasGrid.visible = store.settings.showGrid
app!.stage.addChild(canvasGrid)
world = new Container()
app!.stage.addChild(world)
axisLabelLayer = new Container() // 独立顶层,不随 world 缩放
app!.stage.addChild(axisLabelLayer)
background.createSprite(world)
axisGfx = new Graphics()
world.addChild(axisGfx)
spineRuntime = new SpineRuntimeLayer()
world.addChild(spineRuntime.container)
boneFollowTargetGfx = new Graphics()
world.addChild(boneFollowTargetGfx)
originGfx = new Graphics()
world.addChild(originGfx)
shapeGfx = new Graphics() // 发射器形状范围(绿圆/蓝矩形/锥形)
world.addChild(shapeGfx)
forceFieldGfx = new Graphics() // 选中系统的全局力场范围
world.addChild(forceFieldGfx)
vortexGfx = new Graphics() // 选中系统的全局漩涡范围
world.addChild(vortexGfx)
attractionGfx = new Graphics() // 选中系统的全局吸附范围
world.addChild(attractionGfx)
collisionGfx = new Graphics()
world.addChild(collisionGfx)
pathGfx = new Graphics()
world.addChild(pathGfx)
gizmoGfx = new Graphics() // root 变换 gizmo 手柄
world.addChild(gizmoGfx)
boneGfx = new Graphics()
world.addChild(boneGfx)
}
function onPanDown(e: PointerEvent) {
if (e.button !== 0) return
// 激活变换工具时,始终操作场景树中当前选中的对象。
if (activeTool.value && interactions.selectedTransformTarget()) {
interactions.onGizmoDown(e)
return
}
if (pathEditing.onPointerDown(e)) return
if (interactions.onColliderPointerDown(e)) return
// 吸附点优先于力场;重叠时拖动红色吸附圆。
if (interactions.onAttractionDown(e)) return
// 力场使用全局坐标,按住圆内任意位置时优先拖动力场中心。
if (interactions.onForceFieldDown(e)) return
panView(stageContext, e.clientX, e.clientY)
}
function applyCanvasTheme() {
const palette = canvasPalette()
if (app) app.renderer.background.color = palette.background
if (canvasGrid) canvasGrid.tint = palette.grid
}
function onStageDoubleClick(e: MouseEvent) {
pathEditing.onStageDoubleClick(e)
}
function onBeforeUnload(event: BeforeUnloadEvent) {
if (!store.settings.unsavedChangesPrompt || !editorHistoryState.dirty) return
event.preventDefault()
event.returnValue = ''
}
function returnToWelcome() {
if (store.settings.unsavedChangesPrompt && editorHistoryState.dirty
&& !window.confirm(translate('settings.unsavedLeaveConfirm'))) return
emit('home')
}
function onWheelEvent(e: WheelEvent) {
onWheel(stageContext, e)
}
function clearBackground(resetControls = true) {
BackgroundLayer.clearStoreBackground(store, resetControls)
}
async function onBackgroundFileChange(event: Event) {
await BackgroundLayer.handleFileChange(store, event)
}
useEditorShortcuts({
canvasElement: holder,
canvasFocused,
undo: undoLastOperation,
save: () => window.dispatchEvent(new Event(PROJECT_SAVE_EVENT)),
togglePlay: () => store.togglePlay(),
toStart: () => store.toStart(),
setTool: (tool) => { activeTool.value = tool },
})
function syncEmitters() {
if (!world) return
emitterSync.sync(store, world, dotTex, trailTex, spineRuntime)
}
/** 事件互相连接的粒子系统共享最长暖场帧数,保证子发射器也能从父事件产生。 */
function warmupFramesBySystem() {
const byId = new Map(store.systems.map((system) => [system.id, system]))
const subEmitterIds = collectSubEmitterIds(store.systems)
const neighbors = new Map(store.systems.map((system) => [system.id, new Set<number>()]))
for (const system of store.systems) {
if (system.config.eventEnabled === false) continue
for (const rule of system.config.events || []) {
if (!byId.has(rule.targetSystemId) || rule.targetSystemId === system.id) continue
neighbors.get(system.id)!.add(rule.targetSystemId)
neighbors.get(rule.targetSystemId)!.add(system.id)
}
}
const result = new Map<number, number>()
for (const system of store.systems) {
if (result.has(system.id)) continue
const component: number[] = []
const pending = [system.id]
while (pending.length) {
const id = pending.pop()!
if (component.includes(id)) continue
component.push(id)
pending.push(...neighbors.get(id)!)
}
const frames = Math.max(0, ...component.map((id) => {
if (subEmitterIds.has(id)) return 0
const config = byId.get(id)?.config
if (config?.mode === 'stream' && config.streamBehavior === 'loop') return 0
return Math.floor(Number(config?.warmupFrames) || 0)
}))
for (const id of component) result.set(id, frames)
}
return result
}
/**
* 同步烘焙一个系统的整段帧缓存。
* 与逐帧录制结果一致(同样的固定步长、同样的随机序列),因此不会改变预览内容,
* 只是把"参数变动时就该生成好的烘焙结果"补齐。
*/
function bakeSystemFrames(system: ParticleSystem, emitter: ParticleEmitter, fps: number, totalFrames: number, warmupFrames: number) {
const frameCount = Math.max(2, Math.floor(totalFrames) || 2)
// 循环持续本来就是一次性烘焙,直接复用同一套逻辑。
if (system.config.mode === 'stream' && system.config.streamBehavior === 'loop') {
bakeLoopFrames(system, emitter, fps, frameCount)
return
}
emitter.reset()
const frameDuration = 1 / Math.max(1, fps)
emitter.warmup(warmupFrames, frameDuration)
const frames: ParticleState[][] = []
for (let frame = 0; frame < frameCount; frame++) {
// 第 0 帧使用暖场后的状态;未设置暖场时仍保持从空白开始。
if (frame === 0) frames.push(warmupFrames > 0 ? emitter.capture() : [])
else frames.push(emitter.update(frameDuration))
}
system.frames = frames
}
/**
* 帧缓存缺失时补齐(例如参数刚改完就暂停/拖动时间轴)。
* 缓存本来就完整时不做任何事,保证拖动时间轴不会重新生成预览。
*/
function ensureFramesBaked(fps: number) {
const totalFrames = Math.max(2, Math.floor(store.timeline.totalFrames) || 2)
const subEmitterIds = collectSubEmitterIds(store.systems)
const warmupFrames = warmupFramesBySystem()
// 烘焙预热会大量出生/死亡粒子,期间暂停跨系统事件,避免注入预热粒子。
emitterSync.suspendEvents(true)
try {
for (const system of store.systems) {
// 子发射器由父粒子事件驱动,不能脱离父系统单独烘焙。
if (subEmitterIds.has(system.id)) continue
if ((system.frames?.length || 0) >= totalFrames) continue
const emitter = emitterSync.emitters.get(system.id)
if (!emitter) continue
bakeSystemFrames(system as unknown as ParticleSystem, emitter, fps, totalFrames, warmupFrames.get(system.id) || 0)
}
} finally {
emitterSync.suspendEvents(false)
}
// 子发射器必须通过父事件逐帧录制;只补齐根系统时不能把整段烘焙标记为完成。
if (store.systems.every((system) => (system.frames?.length || 0) >= totalFrames)) {
store.timeline.recorded = true
}
}
function loop() {
raf = requestAnimationFrame(loop)
const now = performance.now() / 1000
let dt = now - lastT
lastT = now
if (dt > 0.1) dt = 0.1
if (app && world) {
world.scale.set(store.editor.viewScale)
world.position.set(app.screen.width / 2 + store.editor.viewX, app.screen.height * 0.5 + store.editor.viewY)
background.applyTransform(store.settings.backgroundX, store.settings.backgroundY, store.settings.backgroundOpacity)
drawAxis(store, app, axisGfx, axisLabelLayer, axisLabels)
// 根节点(root)变换:位置(centerX/centerY)=位移,rotation/scale 绕 root 中心,与场景对象参数联动
const followFps = Math.max(1, store.timeline.fps)
const followFrame = store.timeline.frame
const followTime = followFrame / followFps
// 发射器读取骨骼变换、插槽 drawOrder 之前,先把 Spine 运行时同步到同一时间轴帧。
spineRuntime?.sync(store.spines, followFrame, followFps)
syncEmitters()
// 同步完成后计算插槽层级补偿,抵消插槽骨骼变换,保持粒子系统原有世界坐标表现。
emitterSync.updateRenderCompensation(store, spineRuntime)
previewColliders = buildPreviewColliders(store, followTime)
for (const sys of store.systems) {
const em = emitterSync.emitters.get(sys.id)
if (!em) continue
em.setFollowExportCurveMode(store.settings.exportSettings.keyframeCurve === 'bezier' ? '贝塞尔' : '线性')
const visible = sys.visible !== false
em.visible = visible
em.renderable = visible
const c = sys.config
em.scale.set(c.rootScaleX, c.rootScaleY)
const collisionIds = new Set(c.collisionColliderIds)
em.setColliders(c.collision
? previewColliders.filter((collider) => collisionIds.has(collider.id))
: [])
em.setEmitterFollowSpawn(0, 0, 0)
let preview = { x: c.centerX, y: c.centerY, rotation: c.rootRotation }
const followSpine = c.emitterFollow && c.emitterFollowMode === 'bone'
? store.spines.find((spine) => spine.id === c.emitterFollowSpineId && spine.enabled && spine.status === 'ready')
: null
const boneTransform = followSpine
? spineRuntime?.getBoneWorldTransform(followSpine.id, c.emitterFollowBoneName) || null
: null
const followPath = c.emitterFollow && c.emitterFollowMode === 'path'
? store.paths.find((path) => path.id === c.emitterFollowPathId && path.enabled)
: null
const duration = Math.max(0.05, c.emitterFollowDuration)
const normalizedTime = Math.min(1, Math.max(0, followTime / duration))
const eased = Math.min(1, Math.max(0, followCurveValue(c.emitterFollowCurve, normalizedTime)))
const offset = Math.min(1, Math.max(0, c.emitterFollowOffset))
const pathProgress = c.emitterFollowDirection === 'reverse'
? (1 - offset) * (1 - eased)
: offset + (1 - offset) * eased
const pathSample = followPath ? sampleScenePath(followPath, pathProgress) : null
if (boneTransform) {
// 骨骼只提供位置;旋转和缩放不传递给粒子,偏移使用世界坐标单位。
const offsetX = c.emitterFollowBoneOffsetX
const offsetY = c.emitterFollowBoneOffsetY
const targetX = boneTransform.x + offsetX
const targetY = boneTransform.y + offsetY
const targetRotation = c.rootRotation + c.emitterFollowBoneRotation
const targetScaleX = Math.max(0.0001, Math.abs(c.rootScaleX * c.emitterFollowBoneScaleX))
const targetScaleY = Math.max(0.0001, Math.abs(c.rootScaleY * c.emitterFollowBoneScaleY))
preview = { x: targetX, y: targetY, rotation: targetRotation }
if (c.emitterFollowSpace === 'local') {
// 本地空间:整个发射器容器随骨骼移动,已出生粒子也继承后续骨骼变换。
em.position.set(targetX, -targetY)
em.rotation = -targetRotation * Math.PI / 180
em.scale.set(targetScaleX, targetScaleY)
em.setSimulationTransform(targetX, targetY, targetRotation, targetScaleX, targetScaleY)
} else {
// 世界空间:保持粒子系统根容器不动,只把新粒子的出生点和方向移动到骨骼。
em.position.set(c.centerX, -c.centerY)
em.rotation = -c.rootRotation * Math.PI / 180
em.scale.set(targetScaleX, targetScaleY)
em.setSimulationTransform(c.centerX, c.centerY, c.rootRotation, targetScaleX, targetScaleY)
const local = worldPointToEmitterLocal(c, targetX, targetY, targetScaleX, targetScaleY)
const relativeDirection = -(targetRotation - c.rootRotation)
em.setEmitterFollowSpawn(local.x, local.y, relativeDirection)
}
} else if (pathSample) {
const movementAngle = pathSample.angle + (c.emitterFollowDirection === 'reverse' ? 180 : 0)
const effectiveAngle = c.emitterAngleMode === 'direction' ? movementAngle : c.rootRotation
preview = { x: pathSample.x, y: pathSample.y, rotation: effectiveAngle }
if (c.emitterFollowSpace === 'local') {
em.position.set(pathSample.x, -pathSample.y)
em.rotation = -effectiveAngle * Math.PI / 180
em.setSimulationTransform(pathSample.x, pathSample.y, effectiveAngle)
} else {
em.position.set(c.centerX, -c.centerY)
em.rotation = -c.rootRotation * Math.PI / 180
em.setSimulationTransform(c.centerX, c.centerY, c.rootRotation)
const local = worldPointToEmitterLocal(c, pathSample.x, pathSample.y)
const relativeDirection = c.emitterAngleMode === 'direction' ? -(movementAngle - c.rootRotation) : 0
em.setEmitterFollowSpawn(local.x, local.y, relativeDirection)
}
} else {
em.position.set(c.centerX, -c.centerY) // y 数学上正 → 屏幕取负
em.rotation = -c.rootRotation * Math.PI / 180
em.setSimulationTransform(c.centerX, c.centerY, c.rootRotation)
}
const spawnPath = c.shape === 'path' && c.spawnPathId > 0
? store.paths.find((path) => path.id === c.spawnPathId && path.enabled && path.points.length > 0) || null
: null
em.setSpawnPathSampler(
spawnPath ? (progress) => sampleSpawnPathForEmitter(c, spawnPath, progress) : null,
spawnPath?.closed === true,
)
emitterPreviewTransform.set(sys.id, preview)
}
let total = 0
const collected: BonePreview[] = []
const selectedBoneSystemId = store.settings.showBones && store.activeObjectType === 'particle'
? store.activeObjectId
: 0
const collectSelectedBones = (system: { id: number; visible: boolean } | undefined, emitter: ParticleEmitter, state: ParticleState) => {
if (!system || system.id !== selectedBoneSystemId || system.visible === false) return
appendStateBones(collected, state, emitter.position.x, emitter.position.y)
}
const tl = store.timeline
const playbackRange = store.playbackRange
const fps = tl.fps
const frameDuration = 1 / Math.max(1, fps)
let displayFrame = timelineDisplayFrame
let advanceSteps = 0
if (tl.playing) {
// requestAnimationFrame 只负责刷新画面;真实时间累计满 1/fps 秒才推进一帧。
timelineAccumulator += dt
advanceSteps = Math.floor(timelineAccumulator / frameDuration)
if (advanceSteps > 0) timelineAccumulator -= advanceSteps * frameDuration
} else {
// 暂停时清空余量,恢复播放后从完整的一帧时长重新计时。
timelineAccumulator = 0
// 暂停(含拖动时间轴)时把还没烤完的帧缓存补齐:否则画布只能停在上一次的状态,
// 必须重新触发播放才能看到对应帧。缓存完整时这里不会做任何事。
ensureFramesBaked(fps)
}
if (tl.playing && advanceSteps > 0) {
// dt 已限制为最多 0.1 秒,因此30 FPS下单次最多补算3帧,不会出现无界追帧。
for (let step = 0; step < advanceSteps; step++) {
displayFrame = tl.frame
collected.length = 0
total = 0
const warmedSystems = new Set<number>()
if (!tl.recorded) {
// 录制阶段:固定步长模拟并记录当前帧。
emitterSync.suspendEvents(true)
try {
const subEmitterIds = collectSubEmitterIds(store.systems)
for (const sys of store.systems) {
const emitter = emitterSync.emitters.get(sys.id)
if (!emitter) continue
const isLoopStream = !subEmitterIds.has(sys.id)
&& sys.config.mode === 'stream'
&& sys.config.streamBehavior === 'loop'
// 循环持续是一次性烘焙:只要缓存还没有就补上。录制不一定从第 0 帧开始
// (播放头可能被播放范围钳到 A 帧),只按 tl.frame === 0 判断会漏烘焙,
// 导致整段回放都没有可用帧。
if (isLoopStream) {
if (!sys.frames?.length) bakeLoopFrames(sys as unknown as ParticleSystem, emitter, fps, tl.totalFrames)
} else if (tl.frame === 0) {
emitter.reset()
}
}
} finally {
emitterSync.suspendEvents(false)
}
if (tl.frame === 0) {
const warmupFrames = warmupFramesBySystem()
const targets = store.systems.flatMap((system) => {
const emitter = emitterSync.emitters.get(system.id)
const isLoopStream = system.config.mode === 'stream' && system.config.streamBehavior === 'loop'
const frames = warmupFrames.get(system.id) || 0
return emitter && !isLoopStream && frames > 0 ? [{ system, emitter, frames }] : []
})
const lastFrame = Math.max(0, ...targets.map(({ frames }) => frames))
for (let warmupFrame = 0; warmupFrame < lastFrame; warmupFrame++) {
for (const target of targets) {
if (warmupFrame < target.frames) target.emitter.update(frameDuration)
}
}
for (const { system } of targets) warmedSystems.add(system.id)
}
for (const [systemId, em] of emitterSync.emitters) {
const system = store.systems.find((item) => item.id === systemId)
const isLoopStream = system?.config.mode === 'stream' && system.config.streamBehavior === 'loop'
const loopFrame = isLoopStream
? system.frames?.[tl.frame]
: undefined
const forceEmptyInitialFrame = tl.frame === 0 && !isLoopStream && !warmedSystems.has(systemId)
const states = forceEmptyInitialFrame
? []
: loopFrame || (warmedSystems.has(systemId) ? em.capture() : em.update(frameDuration))
if (forceEmptyInitialFrame) em.apply([])
else if (loopFrame) em.apply(loopFrame)
if (system?.visible !== false) {
for (const state of states) if (state.active) collectSelectedBones(system, em, state)
}
total += states.length
}
for (const sys of store.systems) {
if (!sys.config.texture) continue
sys.frames = sys.frames || []
const emitter = emitterSync.emitters.get(sys.id)
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 = playbackRange.start
tl.recorded = true
}
} else {
// 回放阶段:每经过一个固定帧时长才应用下一帧。
for (const sys of store.systems) {
const emitter = emitterSync.emitters.get(sys.id)
const frame = sys.frames?.[tl.frame]
if (emitter && frame) {
emitter.apply(frame)
for (const state of frame) if (state.active) {
collectSelectedBones(sys, emitter, state)
total++
}
}
}
tl.frame++
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 && tl.abLoop
&& activeLoopRange?.start === playbackRange.start
&& activeLoopRange.end === playbackRange.end
const playbackLastFrame = closesActiveLoop ? playbackRange.end - 1 : playbackRange.end
if (tl.frame > playbackLastFrame) {
if (tl.loop) tl.frame = playbackRange.start
else { tl.frame = playbackRange.end; tl.playing = false; break }
}
}
}
} else {
// 两个固定帧之间或暂停时保持当前画面,不推进粒子模拟。
if (!tl.playing) displayFrame = tl.frame
for (const sys of store.systems) {
const emitter = emitterSync.emitters.get(sys.id)
// 播放中使用最近一次真正推进的显示帧;暂停/逐帧时读取播放头指定帧。
// 回放缓存存在时不再调用 update(0),避免用首次模拟结束后的旧活动状态覆盖数量。
const replayFrameIndex = tl.playing ? timelineDisplayFrame : tl.frame
const frame = sys.frames?.[replayFrameIndex]
const hasPreparedLoopFrames = sys.config.mode === 'stream' && sys.config.streamBehavior === 'loop' && !!frame
// 循环持续会在录制首帧一次性准备完整缓存。即使全局录制尚未结束,帧间也必须继续读取缓存,
// 不能调用 update(0) 回到预演结束时的内部粒子状态,否则画面会在两套状态之间高频闪烁。
if ((tl.recorded || hasPreparedLoopFrames) && emitter && frame) {
emitter.apply(frame)
for (const state of frame) if (state.active) {
collectSelectedBones(sys, emitter, state)
total++
}
} else if (emitter) {
// 尚未录制时,第 0 帧也必须保持绝对空白,不能由 update(0) 提前触发发射。
const states = tl.frame === 0 ? [] : emitter.update(0)
if (tl.frame === 0) emitter.apply([])
if (sys.visible !== false) {
for (const state of states) if (state.active) collectSelectedBones(sys, emitter, state)
}
total += emitter.activeCount
}
}
}
timelineDisplayFrame = displayFrame
spineRuntime?.sync(store.spines, displayFrame, fps)
for (const [, emitter] of emitterSync.emitters) emitter.drawTrailMeshDebug(store.settings.showSkinMesh)
drawCollisionBodies(store, previewColliders, collisionGfx)
drawPaths(store, pathEditing.selectedNodes, pathGfx)
drawBoneFollowTargets(store, spineRuntime, boneFollowTargetGfx)
drawOrigin(store, emitterPreviewTransform, originGfx)
drawShapeRange(store, emitterPreviewTransform, shapeGfx)
drawForceField(store, forceFieldGfx)
drawVortex(store, vortexGfx)
drawAttraction(store, attractionGfx)
drawGizmo(activeTool, interactions.selectedTransformTarget.bind(interactions), gizmoGfx)
drawBones(collected, store, boneGfx)
if (info.value) { info.value.systems = store.systems.length; info.value.particles = total }
}
}
onMounted(async () => {
const el = holder.value!
app = new Application({
width: el.clientWidth || 800,
height: el.clientHeight || 600,
backgroundColor: canvasPalette().background,
backgroundAlpha: 1,
antialias: true,
})
el.appendChild(app.view as unknown as HTMLCanvasElement)
stageResizeObserver = new ResizeObserver(() => {
if (!app) return
const nextWidth = Math.max(1, el.clientWidth)
const previousHeight = app.screen.height
const nextHeight = Math.max(1, el.clientHeight)
// Stage 高度变化时补偿视图中心,避免底部面板折叠导致画布内容整体跳动。
if (Math.abs(nextHeight - previousHeight) > 0.5) store.editor.viewY -= (nextHeight - previousHeight) / 2
app.renderer.resize(nextWidth, nextHeight)
if (canvasGrid) {
canvasGrid.width = nextWidth
canvasGrid.height = nextHeight
}
})
stageResizeObserver.observe(el)
createLayers()
watch(() => store.settings.backgroundImageData, (value) => background.syncTexture(value), { immediate: true })
watch(() => store.settings.showGrid, (visible) => {
if (canvasGrid) canvasGrid.visible = visible
})
watch(() => [
store.settings.uiTheme,
store.settings.canvasColorMode,
store.settings.canvasBackgroundColor,
store.settings.canvasGridColor,
], updateCanvasTheme)
const textures = await loadDefaultTextures()
dotTex = textures.dotTex
trailTex = textures.trailTex
attachDefaultTextures(store, dotTex, trailTex)
info.value = { systems: store.systems.length, particles: 0 }
// 默认从头播放并循环
store.timeline.recorded = false
store.timeline.frame = 0
timelineAccumulator = 0
timelineDisplayFrame = 0
store.timeline.playing = true
store.timeline.loop = true
for (const sys of store.systems) sys.frames = []
// 依据粒子最晚消失时间自动算总帧数
store.recalcTotalFrames()
lastT = performance.now() / 1000
loop()
// 只在"时长相关属性"(delay/duration/lifeMax)真正变化时重算总帧数。
// 用签名字符串比较,避免 deep 监听 config(emit 每帧改 _elapsed 等内部字段)导致每帧重算卡顿。
watch(
() =>
store.systems
.map((s) => JSON.stringify(s.config, (k, v) => (k === 'texture' || k === 'trailTexture' || k === 'previewUrl' ? undefined : v)))
.join('|') + `|colliders:${JSON.stringify(store.colliders)}|paths:${JSON.stringify(store.paths)}`,
() => {
const frames = store.recalcTotalFrames()
store.timeline.recorded = false
store.timeline.frame = 0
for (const sys of store.systems) sys.frames = []
store.timeline.playing = true
store.timeline.loop = true
},
)
installEditorHistory(store)
window.addEventListener('beforeunload', onBeforeUnload)
})
onUnmounted(() => {
window.removeEventListener('beforeunload', onBeforeUnload)
cancelAnimationFrame(raf)
stageResizeObserver?.disconnect()
stageResizeObserver = null
spineRuntime?.destroy()
spineRuntime = null
background.destroy()
canvasGrid?.texture.destroy(true)
canvasGrid = null
if (world && app) emitterSync.destroy(world)
if (app) { app.destroy(true, { children: true }); app = null }
})
watch(() => [store.activeObjectType, store.activeObjectId], () => pathEditing.resetSelection())
</script>
<style scoped>
.stage-wrap { position: relative; display: flex; width: 100%; height: 100%; min-height: 0; flex-direction: column; overflow: hidden; }
.stage-holder {
width: 100%; min-height: 0; flex: 1; cursor: grab; touch-action: none;
outline: none;
}
.stage-holder:active { cursor: grabbing; }
.stage-watermark {
position: absolute; top: 14px; left: 14px; z-index: 1;
width: clamp(105px, 13.5vw, 150px); height: auto;
opacity: .8; pointer-events: none; user-select: none;
}
.stage-watermark.watermark-left-panel { left: auto; right: 14px; }
.stage-home-btn {
position: absolute; top: 14px; left: calc(24px + clamp(105px, 13.5vw, 150px)); z-index: 2;
display: grid; width: 32px; height: 32px; padding: 7px; place-items: center;
border: 1px solid var(--ui-border-translucent); border-radius: 7px;
background: var(--ui-overlay-soft); color: var(--ui-text-body); cursor: pointer;
backdrop-filter: blur(6px);
}
.stage-home-btn.home-right { left: auto; right: calc(24px + clamp(105px, 13.5vw, 150px)); }
.stage-home-btn:hover { border-color: var(--ui-accent-strong); background: var(--ui-control-active); color: #fff; }
.stage-home-btn:focus-visible { outline: 2px solid var(--ui-focus); outline-offset: 2px; }
.stage-home-btn svg { display: block; width: 100%; height: 100%; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linejoin: round; }
</style>