perf(editor): 优化播放头拖动性能

This commit is contained in:
2026-09-21 00:04:25 +08:00
parent 5cc2e26a29
commit 07d9e5e798
7 changed files with 141 additions and 56 deletions

View File

@@ -20,7 +20,7 @@ beforeEach(() => {
it("renders the director desk header and view mode switch", () => {
const { container } = render(<App />);
expect(screen.getByText("3D导演台")).toBeInTheDocument();
expect(screen.queryByText("3D导演台")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "导演视角" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "机位视角" })).toBeInTheDocument();
expect(container.querySelector(".top-bar-center .mode-toggle")).toBeInTheDocument();

View File

@@ -79,9 +79,9 @@ export default function App() {
return (
<div className="app-shell">
<header className="top-bar">
<div className="top-bar-left">
<h1 className="top-bar-title">3D导演台</h1>
</div>
{/* 占位:顶栏是「左栏宽 / 中间 1fr / 右栏宽」三列栅格,
少了这块,中间的视角切换会落到左列里去,左对齐而不是居中。 */}
<div className="top-bar-left" />
<div className="top-bar-center">
<div className="mode-toggle ui-segmented" role="group" aria-label="视角切换">
<button

View File

@@ -1,6 +1,6 @@
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import { PerspectiveCamera as DreiPerspectiveCamera } from "@react-three/drei";
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { memo, useLayoutEffect, useMemo, useRef, useState } from "react";
import { PerspectiveCamera as ThreePerspectiveCamera, Raycaster, Scene, Vector2 } from "three";
import { useDirectorStore } from "../store/directorStore";
import { usePlaybackStore } from "../runtime/playbackStore";
@@ -45,14 +45,12 @@ function LensMonitorCamera({
sceneSettings,
sceneRef,
camRef,
playhead,
cameraTracks,
}: {
camera: DirectorCameraShot;
sceneSettings: SceneSettings;
sceneRef: React.MutableRefObject<Scene | null>;
camRef: React.MutableRefObject<ThreePerspectiveCamera | null>;
playhead: number;
cameraTracks: CameraTimelineTrack[];
}) {
const { gl, scene } = useThree();
@@ -63,6 +61,11 @@ function LensMonitorCamera({
const transitionClips = useDirectorStore((state) => state.project.timeline.transitionClips);
const globalTransition = useDirectorStore((state) => state.project.timeline.cameraTransition);
const isExporting = usePlaybackStore((state) => state.isExporting);
// playhead 自己订阅(而不是从外层当 prop 传进来):外壳组件不必因为这个每秒变几十次的值
// 去 reconcile 整棵场景树(见 LensMonitorCanvas 的 memo)。
const playhead = usePlaybackStore((state) => state.playhead);
// 拖动播放头期间走草稿档:跳过景深整套后处理,把帧预算留给「跟手」
const isScrubbing = usePlaybackStore((state) => state.isScrubbing);
// 关键帧感知:拖播放头时,监视器上的对焦距离 / 光圈 / 焦段 / 景深强度都按关键帧插值,
// 让「移焦 / 渐入渐出虚化 / 变焦」在监视器上所见即所得(与导出管线一致的 resolveLensAtTime)。
const lens = useMemo(
@@ -125,7 +128,8 @@ function LensMonitorCamera({
// 与主视口 / 视频导出共用同一份景深渲染(renderSceneWithBokeh):监视器不再自建一套
// EffectComposer —— 两套实现很容易漂移,导出与监视器的取景 / 虚化就会对不上。
// 合成器按 gl 缓存、画布尺寸变化会自动重建;不支持后处理时回退普通渲染,监视器永远可用。
renderSceneWithBokeh(gl, scene, renderCamera, lens);
// 拖动播放头时传 draft:跳过整套景深后处理(松手立刻恢复),拖动才跟手。
renderSceneWithBokeh(gl, scene, renderCamera, lens, undefined, { draft: isScrubbing });
}, 1);
return (
@@ -139,6 +143,71 @@ function LensMonitorCamera({
);
}
/**
* 监视器那块 3D 画布:整棵场景树(SceneRoot 会把当前工程所有物体铺进来)。
*
* 单独抽成 memo 组件的原因:拖动播放头时 playhead 每秒变几十次,如果这棵树长在外壳组件里,
* 每动一下都要把整棵场景树 reconcile 一遍 —— 那是拖动卡顿的一大来源。这里 props 只在
* 「换机位 / 改场景 / 换全景图」这类真实编辑时变,播放头由 LensMonitorCamera 自己订阅。
*/
const LensMonitorCanvas = memo(function LensMonitorCanvas({
camera,
sceneSettings,
panoramaAsset,
cameraTracks,
sceneRef,
camRef,
initialCamera,
}: {
camera: DirectorCameraShot;
sceneSettings: SceneSettings;
panoramaAsset: React.ComponentProps<typeof ViewportBackground>["panoramaAsset"];
cameraTracks: CameraTimelineTrack[];
sceneRef: React.MutableRefObject<Scene | null>;
camRef: React.MutableRefObject<ThreePerspectiveCamera | null>;
/** 画布的初始相机参数:只取首次渲染那一刻的值(位姿 / fov 由 LensMonitorCamera 每帧写) */
initialCamera: { fov: number; position: [number, number, number] };
}) {
return (
<Canvas
className="lens-monitor-canvas"
// 这块画布在机位视角下就是主视口那块画幅(尺寸跟着画幅走),所以 dpr 上限收一档:
// 大画布上跑到 2x 会让「景深 composer + 深度 pass」的像素量翻倍,是之前发卡的主因;
// 1.5x 在高分屏上肉眼几乎无差别,开销却少 ~45%。导出不受影响(导出自己设 1x + 导出分辨率)。
dpr={[1, 1.5]}
// preserveDrawingBuffer:机位视角导出就是把这块画布的内容逐帧读进 VideoFrame,
// 不保留绘画缓冲会读到空帧。
gl={{ antialias: true, preserveDrawingBuffer: true }}
camera={initialCamera}
>
<color attach="background" args={[sceneSettings.backgroundColor]} />
{/* 与主视口 / 导出共用同一份背景实现:有全景图时渲染全景(equirect 走 scene.background、
backdrop 走穹顶网格),否则用背景色。少了它,监视器会和导出画面差一个背景。 */}
<ViewportBackground
backgroundColor={sceneSettings.backgroundColor}
panoramaAsset={panoramaAsset}
panoramaRadius={sceneSettings.panoramaRadius}
panoramaYaw={sceneSettings.panoramaYaw}
/>
<ambientLight intensity={1.15} />
<directionalLight intensity={1.2} position={[8, 10, 6]} />
{/* 与主视口 / 视频导出共用同一份时间轴变换:把物体 / 角色关键帧的插值写进监视器场景。
SceneRoot 自身只取 item.transform 和人物轨迹,不应用时间轴轨道 —— 少了这一层,
监视器里的物体就不会随关键帧运动,物体到相机的深度和导出不同,
清晰平面落在别处,看起来就是「对焦距离和导出对不上」。 */}
<PlaybackTransformSync />
<LensMonitorCamera
camera={camera}
sceneSettings={sceneSettings}
sceneRef={sceneRef}
camRef={camRef}
cameraTracks={cameraTracks}
/>
<SceneRoot editorHelpers={false} />
</Canvas>
);
});
/** 鼠标每像素对应的角度增量(度) */
const LOOK_DRAG_SENSITIVITY = 0.3;
/** 俯仰角夹到 ±89°,避免越过天顶后镜头翻转 */
@@ -230,6 +299,11 @@ export function LensMonitor({ rightOffset = 12 }: { rightOffset?: number }) {
}, [sensorAspect]);
const viewportCameraFrame = viewMode === "camera" && camera ? viewportFrame : null;
// 画布初始相机参数只在首次渲染那一刻取一次:位姿 / fov 每帧都由 LensMonitorCamera 写,
// 保持这个对象引用稳定,<Canvas> 才不会因为播放头变化被重新应用 camera props。
const initialCanvasCameraRef = useRef<{ fov: number; position: [number, number, number] } | null>(null);
initialCanvasCameraRef.current ??= { fov: lens?.fov ?? 50, position: [0, 1, 5] };
const initialCanvasCamera = initialCanvasCameraRef.current;
const [dragging, setDragging] = useState(false);
const dragRef = useRef<{
startX: number;
@@ -339,43 +413,15 @@ export function LensMonitor({ rightOffset = 12 }: { rightOffset?: number }) {
onPointerCancel={endDrag}
>
{camera && lens ? (
<Canvas
className="lens-monitor-canvas"
// 这块画布在机位视角下就是主视口那块画幅(尺寸跟着画幅走),所以 dpr 上限收一档:
// 大画布上跑到 2x 会让「景深 composer + 深度 pass」的像素量翻倍,是之前发卡的主因;
// 1.5x 在高分屏上肉眼几乎无差别,开销却少 ~45%。导出不受影响(导出自己设 1x + 导出分辨率)。
dpr={[1, 1.5]}
// preserveDrawingBuffer:机位视角导出就是把这块画布的内容逐帧读进 VideoFrame,
// 不保留绘画缓冲会读到空帧。
gl={{ antialias: true, preserveDrawingBuffer: true }}
camera={{ fov: lens.fov, position: [0, 1, 5] }}
>
<color attach="background" args={[sceneSettings.backgroundColor]} />
{/* 与主视口 / 导出共用同一份背景实现:有全景图时渲染全景(equirect 走 scene.background、
backdrop 走穹顶网格),否则用背景色。少了它,监视器会和导出画面差一个背景。 */}
<ViewportBackground
backgroundColor={sceneSettings.backgroundColor}
panoramaAsset={panoramaAsset}
panoramaRadius={sceneSettings.panoramaRadius}
panoramaYaw={sceneSettings.panoramaYaw}
/>
<ambientLight intensity={1.15} />
<directionalLight intensity={1.2} position={[8, 10, 6]} />
{/* 与主视口 / 视频导出共用同一份时间轴变换:把物体 / 角色关键帧的插值写进监视器场景。
SceneRoot 自身只取 item.transform 和人物轨迹,不应用时间轴轨道 —— 少了这一层,
监视器里的物体就不会随关键帧运动,物体到相机的深度和导出不同,
清晰平面落在别处,看起来就是「对焦距离和导出对不上」。 */}
<PlaybackTransformSync />
<LensMonitorCamera
<LensMonitorCanvas
camera={camera}
sceneSettings={sceneSettings}
sceneRef={monitorSceneRef}
camRef={monitorCamRef}
playhead={playhead}
cameraTracks={cameraTracks}
camRef={monitorCamRef}
initialCamera={initialCanvasCamera}
panoramaAsset={panoramaAsset}
sceneRef={monitorSceneRef}
sceneSettings={sceneSettings}
/>
<SceneRoot editorHelpers={false} />
</Canvas>
) : (
<div className="lens-monitor-empty">无激活机位</div>
)}

View File

@@ -147,8 +147,18 @@ export function renderSceneWithBokeh(
scene: Scene,
camera: PerspectiveCamera,
lens: ResolvedLens,
viewportRect?: { left: number; top: number; width: number; height: number }
viewportRect?: { left: number; top: number; width: number; height: number },
options: { draft?: boolean } = {}
): void {
// 草稿档(拖动播放头时用):直接普通渲染,跳过「深度 pass + 41~96 点景深」整套。
// 拖动期间人看的是时间与构图,这里省掉的是最贵的那几笔(约 2~3 倍开销),松手立刻恢复完整景深。
// 特意没采用「拖动时降分辨率」:那会重建合成器的渲染目标(几十 MB 显存回收 + 重新分配),
// 反倒让每次开始拖动的头几帧卡一下。
if (options.draft) {
gl.render(scene, camera);
return;
}
const state = ensureBokehState(
gl,
scene,

View File

@@ -1213,6 +1213,19 @@ export function TimelinePanel() {
const rulerRef = useRef<HTMLDivElement | null>(null);
const rulerRectRef = useRef<DOMRect | null>(null);
const [isScrubbing, setIsScrubbing] = useState(false);
// 拖动播放头:指针事件频率远高于刷新率(高回报率鼠标 / 触控板一秒几百次),
// 逐条 setPlayhead 会把帧预算全花在 React 重渲染上。只记最新位置,按帧应用一次。
const scrubClientXRef = useRef<number | null>(null);
const scrubFrameRef = useRef(0);
function cancelScrubFrame() {
if (scrubFrameRef.current === 0) return;
cancelAnimationFrame(scrubFrameRef.current);
scrubFrameRef.current = 0;
}
// 卸载时兜底:扔掉待执行的 scrub 帧,别在组件已经没了之后还去写 playhead
useEffect(() => () => cancelScrubFrame(), []);
const rulerTicks = useMemo(() => getTimelineRulerTicks(duration), [duration]);
const rulerMinorTicks = useMemo(
@@ -1961,6 +1974,10 @@ export function TimelinePanel() {
event.preventDefault();
setIsScrubbing(true);
setScrubbing(true);
// 拖动播放头期间同样冻结全站毛玻璃:面板 / 画幅遮罩都带 backdrop-filter,
// 它们背后是整张 WebGL 画面(机位视角下就是监视器那块大画面),只要被重绘就会
// 整块重算背景模糊 —— 这正是「拖动轨道卡死」的同一条原因,拖播放头一样躲不掉。
document.body.classList.add("is-timeline-dragging");
seekTo(time);
try {
@@ -1970,18 +1987,36 @@ export function TimelinePanel() {
}
}
/** 按帧应用一次拖动位置(指针事件可能一秒几百条,这里只保留最后一条) */
function applyScrubFrame() {
scrubFrameRef.current = 0;
const clientX = scrubClientXRef.current;
if (clientX === null) return;
const time = getTimeFromPointer(clientX);
if (time !== null) setPlayhead(time);
}
function handleRulerPointerMove(event: ReactPointerEvent<HTMLDivElement>) {
if (!isScrubbing) return;
const time = getTimeFromPointer(event.clientX);
if (time !== null) setPlayhead(time);
scrubClientXRef.current = event.clientX;
if (scrubFrameRef.current !== 0) return;
scrubFrameRef.current = requestAnimationFrame(applyScrubFrame);
}
function handleRulerPointerEnd(event: ReactPointerEvent<HTMLDivElement>) {
if (!isScrubbing) return;
// 松手前先把最后一帧的位置补上,避免丢掉最后一次移动
cancelScrubFrame();
if (scrubClientXRef.current !== null) {
const time = getTimeFromPointer(scrubClientXRef.current);
if (time !== null) setPlayhead(time);
scrubClientXRef.current = null;
}
setIsScrubbing(false);
setScrubbing(false);
document.body.classList.remove("is-timeline-dragging");
rulerRectRef.current = null;
try {
event.currentTarget.releasePointerCapture(event.pointerId);

View File

@@ -140,13 +140,6 @@ textarea:focus-visible {
font-family: "PingFang SC", "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.top-bar-title {
margin: 0;
font-size: 16px;
font-weight: 600;
line-height: 22px;
}
.top-bar-left,
.top-bar-center {
display: flex;

View File

@@ -57,7 +57,8 @@ it("matches the provided top bar and view switch dimensions", () => {
expect(css).toContain("grid-template-columns: var(--left-sidebar-width) minmax(0, 1fr) var(--right-sidebar-width);");
expect(css).toContain("min-height: 70px;");
expect(css).toMatch(/\.top-bar-title\s*\{[\s\S]*?font-size:\s*16px;[\s\S]*?line-height:\s*22px;/);
// 顶栏标题已去掉(只留中间的视角切换),别再被加回来
expect(css).not.toContain(".top-bar-title");
expect(css).toMatch(/\.mode-toggle\s*\{[\s\S]*?width:\s*212px;[\s\S]*?height:\s*44px;[\s\S]*?border-radius:\s*12px;/);
expect(css).toMatch(/\.mode-toggle-button\s*\{[\s\S]*?width:\s*100px;[\s\S]*?height:\s*36px;[\s\S]*?font-size:\s*14px;[\s\S]*?line-height:\s*20px;/);
expect(css).toMatch(/\.mode-toggle-button\[aria-pressed="true"\]\s*\{[\s\S]*?border-color:\s*rgb\(var\(--accent-rgb\) \/ 0\.28\);[\s\S]*?color:\s*rgb\(var\(--accent-rgb\)\);[\s\S]*?background:\s*rgb\(var\(--accent-rgb\) \/ 0\.12\);/);