perf(editor): 优化播放头拖动性能
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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\);/);
|
||||
|
||||
Reference in New Issue
Block a user