From 2ba2b9c08a1ff24e1c227223550d029b2f486326 Mon Sep 17 00:00:00 2001 From: tianmo Date: Fri, 9 Oct 2026 17:21:34 +0800 Subject: [PATCH] =?UTF-8?q?=E6=97=B6=E9=97=B4=E8=BD=B4=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=E4=B8=8E=E9=A2=84=E8=A7=88=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 界面文字全局禁止鼠标框选,输入类控件保留可选中 - 拖动时间轴立即暂停自动播放,避免与播放头抢时间轴 - 暂停时补齐未完成的帧缓存,拖动时间轴直接读取已烘焙帧,无需重新触发播放 - 循环持续的烘焙不再依赖从第 0 帧开始录制,避免整段缓存为空 --- src/style.css | 5 ++++ src/views/Stage.vue | 61 +++++++++++++++++++++++++++++++++++------- src/views/Timeline.vue | 2 ++ 3 files changed, 59 insertions(+), 9 deletions(-) diff --git a/src/style.css b/src/style.css index f2f0483..7fb5965 100644 --- a/src/style.css +++ b/src/style.css @@ -13,6 +13,11 @@ body { position: fixed; inset: 0; } * { box-sizing: border-box; } #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']::-webkit-inner-spin-button, diff --git a/src/views/Stage.vue b/src/views/Stage.vue index 90949a9..8e6aba8 100644 --- a/src/views/Stage.vue +++ b/src/views/Stage.vue @@ -192,6 +192,44 @@ function syncEmitters() { 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() { raf = requestAnimationFrame(loop) const now = performance.now() / 1000 @@ -323,6 +361,9 @@ function loop() { } else { // 暂停时清空余量,恢复播放后从完整的一帧时长重新计时。 timelineAccumulator = 0 + // 暂停(含拖动时间轴)时把还没烤完的帧缓存补齐:否则画布只能停在上一次的状态, + // 必须重新触发播放才能看到对应帧。缓存完整时这里不会做任何事。 + ensureFramesBaked(fps) } if (tl.playing && advanceSteps > 0) { @@ -333,15 +374,17 @@ function loop() { total = 0 if (!tl.recorded) { // 录制阶段:固定步长模拟并记录当前帧。 - if (tl.frame === 0) { - for (const sys of store.systems) { - const emitter = emitterSync.emitters.get(sys.id) - if (!emitter) continue - if (sys.config.mode === 'stream' && sys.config.streamBehavior === 'loop') { - bakeLoopFrames(sys as unknown as ParticleSystem, emitter, fps, tl.totalFrames) - } else { - emitter.reset() - } + for (const sys of store.systems) { + const emitter = emitterSync.emitters.get(sys.id) + if (!emitter) continue + const isLoopStream = 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() } } for (const [systemId, em] of emitterSync.emitters) { diff --git a/src/views/Timeline.vue b/src/views/Timeline.vue index fb9963f..1174cc9 100644 --- a/src/views/Timeline.vue +++ b/src/views/Timeline.vue @@ -355,6 +355,8 @@ function onWheel(e: WheelEvent) { // 拖动时间轴滑动(scrub):更新 frame(0..totalFrames-1) function onScrubDown(e: PointerEvent) { const vp = vpRef.value! + // 用户接管时间轴时立即暂停播放,否则每帧推进会和拖动抢播放头。 + store.pause() const move = (ev: PointerEvent) => { const rect = vp.getBoundingClientRect() const f = Math.floor(((ev.clientX - rect.left) / rect.width) * viewFrames.value)