Changes to be committed:

modified:   src/style.css
	modified:   src/views/Stage.vue
	modified:   src/views/Timeline.vue
This commit is contained in:
tianmo committed 2026-10-09 17:21:34 +08:00
1 parent d6232c9d66
commit 0f08224e1c
3 files changed
+59 -9

No files matched your search

+5
View File
@@ -13,6 +13,11 @@ body { position: fixed; inset: 0; }
* { box-sizing: border-box; } * { box-sizing: border-box; }
#app { position: fixed; inset: 0; width: auto; height: auto; overflow: clip; } #app { position: fixed; inset: 0; width: auto; height: auto; overflow: clip; }
/* 界面文字不参与鼠标框选:拖动滑块/手柄时不会误选中标签文字。
输入类控件仍可选中,方便编辑和复制数值。 */
html, body, #app { -webkit-user-select: none; user-select: none; }
input, textarea, select, [contenteditable='true'] { -webkit-user-select: text; user-select: text; }
/* 数值由滑块、键盘或直接输入调整,统一隐藏浏览器自带的上下微调箭头。 */ /* 数值由滑块、键盘或直接输入调整,统一隐藏浏览器自带的上下微调箭头。 */
input[type='number'] { appearance: textfield; -moz-appearance: textfield; } input[type='number'] { appearance: textfield; -moz-appearance: textfield; }
input[type='number']::-webkit-inner-spin-button, input[type='number']::-webkit-inner-spin-button,
+52 -9
View File
@@ -192,6 +192,44 @@ function syncEmitters() {
emitterSync.sync(store, world, dotTex, trailTex) emitterSync.sync(store, world, dotTex, trailTex)
} }
/**
* 同步烘焙一个系统的整段帧缓存。
* 与逐帧录制结果一致(同样的固定步长、同样的随机序列),因此不会改变预览内容,
* 只是把"参数变动时就该生成好的烘焙结果"补齐。
*/
function bakeSystemFrames(system: ParticleSystem, emitter: ParticleEmitter, fps: number, totalFrames: 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)
const frames: ParticleState[][] = []
for (let frame = 0; frame < frameCount; frame++) {
// 与逐帧录制保持一致:第 0 帧保持空白,避免正式播放前提前发射。
if (frame === 0) { emitter.apply([]); frames.push([]) } else frames.push(emitter.update(frameDuration))
}
system.frames = frames
}
/**
* 帧缓存缺失时补齐(例如参数刚改完就暂停/拖动时间轴)。
* 缓存本来就完整时不做任何事,保证拖动时间轴不会重新生成预览。
*/
function ensureFramesBaked(fps: number) {
const totalFrames = Math.max(2, Math.floor(store.timeline.totalFrames) || 2)
let complete = true
for (const system of store.systems) {
if ((system.frames?.length || 0) >= totalFrames) continue
const emitter = emitterSync.emitters.get(system.id)
if (!emitter) { complete = false; continue }
bakeSystemFrames(system as unknown as ParticleSystem, emitter, fps, totalFrames)
}
if (complete) store.timeline.recorded = true
}
function loop() { function loop() {
raf = requestAnimationFrame(loop) raf = requestAnimationFrame(loop)
const now = performance.now() / 1000 const now = performance.now() / 1000
@@ -323,6 +361,9 @@ function loop() {
} else { } else {
// 暂停时清空余量,恢复播放后从完整的一帧时长重新计时。 // 暂停时清空余量,恢复播放后从完整的一帧时长重新计时。
timelineAccumulator = 0 timelineAccumulator = 0
// 暂停(含拖动时间轴)时把还没烤完的帧缓存补齐:否则画布只能停在上一次的状态,
// 必须重新触发播放才能看到对应帧。缓存完整时这里不会做任何事。
ensureFramesBaked(fps)
} }
if (tl.playing && advanceSteps > 0) { if (tl.playing && advanceSteps > 0) {
@@ -333,15 +374,17 @@ function loop() {
total = 0 total = 0
if (!tl.recorded) { if (!tl.recorded) {
// 录制阶段:固定步长模拟并记录当前帧。 // 录制阶段:固定步长模拟并记录当前帧。
if (tl.frame === 0) { for (const sys of store.systems) {
for (const sys of store.systems) { const emitter = emitterSync.emitters.get(sys.id)
const emitter = emitterSync.emitters.get(sys.id) if (!emitter) continue
if (!emitter) continue const isLoopStream = sys.config.mode === 'stream' && sys.config.streamBehavior === 'loop'
if (sys.config.mode === 'stream' && sys.config.streamBehavior === 'loop') { // 循环持续是一次性烘焙:只要缓存还没有就补上。录制不一定从第 0 帧开始
bakeLoopFrames(sys as unknown as ParticleSystem, emitter, fps, tl.totalFrames) // (播放头可能被播放范围钳到 A 帧),只按 tl.frame === 0 判断会漏烘焙,
} else { // 导致整段回放都没有可用帧。
emitter.reset() if (isLoopStream) {
} if (!sys.frames?.length) bakeLoopFrames(sys as unknown as ParticleSystem, emitter, fps, tl.totalFrames)
} else if (tl.frame === 0) {
emitter.reset()
} }
} }
for (const [systemId, em] of emitterSync.emitters) { for (const [systemId, em] of emitterSync.emitters) {
+2
View File
@@ -355,6 +355,8 @@ function onWheel(e: WheelEvent) {
// 拖动时间轴滑动(scrub):更新 frame(0..totalFrames-1) // 拖动时间轴滑动(scrub):更新 frame(0..totalFrames-1)
function onScrubDown(e: PointerEvent) { function onScrubDown(e: PointerEvent) {
const vp = vpRef.value! const vp = vpRef.value!
// 用户接管时间轴时立即暂停播放,否则每帧推进会和拖动抢播放头。
store.pause()
const move = (ev: PointerEvent) => { const move = (ev: PointerEvent) => {
const rect = vp.getBoundingClientRect() const rect = vp.getBoundingClientRect()
const f = Math.floor(((ev.clientX - rect.left) / rect.width) * viewFrames.value) const f = Math.floor(((ev.clientX - rect.left) / rect.width) * viewFrames.value)