refactor(io): 机位视角导出改用监视器画布渲染

This commit is contained in:
tongtong committed 2026-09-20 08:39:07 +08:00
1 parent 7e28c73643
commit f778f05957
7 files changed
+191 -101

No files matched your search

+35 -78
View File
@@ -1,9 +1,10 @@
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import { PerspectiveCamera as DreiPerspectiveCamera } from "@react-three/drei";
import { useEffect, useMemo, useRef, useState } from "react";
import { 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";
import { setMonitorRenderContext } from "../io/monitorRenderBridge";
import { SceneRoot } from "./SceneRoot";
import { ViewportBackground } from "./ViewportBackground";
import { PlaybackTransformSync } from "./PlaybackTransformSync";
@@ -54,6 +55,7 @@ function LensMonitorCamera({
const cameras = useDirectorStore((state) => state.project.cameras);
const transitionClips = useDirectorStore((state) => state.project.timeline.transitionClips);
const globalTransition = useDirectorStore((state) => state.project.timeline.cameraTransition);
const isExporting = usePlaybackStore((state) => state.isExporting);
// 关键帧感知:拖播放头时,监视器上的对焦距离 / 光圈 / 焦段 / 景深强度都按关键帧插值,
// 让「移焦 / 渐入渐出虚化 / 变焦」在监视器上所见即所得(与导出管线一致的 resolveLensAtTime)。
const lens = useMemo(
@@ -84,6 +86,15 @@ function LensMonitorCamera({
}, [camera, cameras, cameraTracks, transitionClips, globalTransition, playhead, sceneSettings]);
const sensorAspect = lens.sensor.widthMm / lens.sensor.heightMm;
// 把监视器的渲染三件套注册给视频导出:机位视角导出直接用这块画布 / 这颗相机 / 这个场景出片,
// 因此导出的画面与监视器所见是同一份渲染,逐像素一致。
useLayoutEffect(() => {
const renderCamera = cameraRef.current;
if (!renderCamera) return undefined;
setMonitorRenderContext({ gl, scene, camera: renderCamera });
return () => setMonitorRenderContext(null);
}, [gl, scene]);
useFrame(() => {
const renderCamera = cameraRef.current;
if (!renderCamera || !worldView) return;
@@ -100,6 +111,10 @@ function LensMonitorCamera({
renderCamera.updateProjectionMatrix();
camRef.current = renderCamera;
// 导出期间由 videoExport 在这块画布上逐帧渲染(同一颗 gl / 同一个场景 / 同一颗相机),
// 这里跳过,避免和导出循环双重渲染、也避免争用同一份景深合成器缓存。
if (isExporting) return;
// 与主视口 / 视频导出共用同一份景深渲染(renderSceneWithBokeh):监视器不再自建一套
// EffectComposer —— 两套实现很容易漂移,导出与监视器的取景 / 虚化就会对不上。
// 合成器按 gl 缓存、画布尺寸变化会自动重建;不支持后处理时回退普通渲染,监视器永远可用。
@@ -124,70 +139,8 @@ const PITCH_LIMIT = 89;
/** 区分「单击对焦」与「拖拽瞄准」的位移阈值(像素) */
const CLICK_THRESHOLD_PX = 4;
/**
* 机位视角下监视器的「镜像」:主视口此刻渲染的就是机位画面,监视器没必要再开一个 Canvas
* 把整个场景重渲一遍(第二份场景 + 第二套景深后处理,既卡,又容易和主视口取景 / 虚化漂移)。
* 这里直接把主视口画布按「传感器画幅」居中裁一块拷过来,逐像素与主视口一致。
*
* fov 是垂直的:视口比传感器画幅更宽时按宽度裁,垂直取景保持不变 ——
* 结果与监视器自己用传感器画幅渲染完全一致。
*/
function MonitorMirror({ sensorAspect }: { sensorAspect: number }) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
let frame = 0;
const draw = () => {
frame = requestAnimationFrame(draw);
const target = canvasRef.current;
const source = document.querySelector<HTMLCanvasElement>(".director-canvas canvas");
if (!target || !source) return;
// 输出尺寸跟随容器(CSS 像素)
const cssWidth = target.clientWidth;
const cssHeight = target.clientHeight;
if (!cssWidth || !cssHeight) return;
if (target.width !== cssWidth || target.height !== cssHeight) {
target.width = cssWidth;
target.height = cssHeight;
}
const sourceWidth = source.width;
const sourceHeight = source.height;
if (!sourceWidth || !sourceHeight) return;
let cropWidth = sourceWidth;
let cropHeight = Math.round(sourceWidth / sensorAspect);
if (cropHeight > sourceHeight) {
cropHeight = sourceHeight;
cropWidth = Math.round(sourceHeight * sensorAspect);
}
const context = target.getContext("2d");
if (!context) return;
context.drawImage(
source,
Math.round((sourceWidth - cropWidth) / 2),
Math.round((sourceHeight - cropHeight) / 2),
cropWidth,
cropHeight,
0,
0,
target.width,
target.height
);
};
frame = requestAnimationFrame(draw);
return () => cancelAnimationFrame(frame);
}, [sensorAspect]);
return <canvas className="lens-monitor-canvas" ref={canvasRef} />;
}
export function LensMonitor({ rightOffset = 12 }: { rightOffset?: number }) {
const sceneSettings = useDirectorStore((state) => state.project.scene);
const viewMode = useDirectorStore((state) => state.viewMode);
const assets = useDirectorStore((state) => state.project.assets);
const panoramaAssetId = useDirectorStore((state) => state.project.panoramaAssetId);
const panoramaAsset = assets.find((item) => item.id === panoramaAssetId);
@@ -199,16 +152,23 @@ export function LensMonitor({ rightOffset = 12 }: { rightOffset?: number }) {
const beginUndoBatch = useDirectorStore((state) => state.beginUndoBatch);
const endUndoBatch = useDirectorStore((state) => state.endUndoBatch);
const playhead = usePlaybackStore((state) => state.playhead);
const isPlaying = usePlaybackStore((state) => state.isPlaying);
const isScrubbing = usePlaybackStore((state) => state.isScrubbing);
const isExporting = usePlaybackStore((state) => state.isExporting);
const cameras = useDirectorStore((state) => state.project.cameras);
const transitionClips = useDirectorStore((state) => state.project.timeline.transitionClips);
const cameraTracks = useDirectorStore((state) => state.project.timeline.cameraTracks);
// 当前时刻「正在显示」的机位:与视频导出用同一个 getActiveCameraIdAtTime 解析。
// 多机位自动切换 / 过渡时,监视器画面就是这台机位;它生效的镜头(焦段 / 光圈 / 对焦 / 景深强度)
// 和画幅也必须取这台机位 —— 否则会出现「画面是 B 机位、虚化却用了激活的 A 机位」,
// 导出(按显示机位解析镜头)就会和监视器对不上。
// 当前时刻「正在显示」的机位:与机位视角(ActiveCameraPlaybackSync)用同一套选取逻辑 ——
// 播放 / 导出 / scrub 时按时间轴关键帧自动切(也与导出的 getActiveCameraIdAtTime 一致),
// 静止编辑时沿用手动选中的机位。机位视角和监视器因此永远显示同一台机位,不会出现
// 「主视口是手选的 A 机位、监视器却是时间轴上的 B 机位」这种画面 / 虚化都对不上的情况。
const autoCut = isPlaying || isExporting || isScrubbing;
const displayCameraId = useMemo(
() => getActiveCameraIdAtTime(transitionClips, cameraTracks, activeCamera?.id ?? null, playhead),
[transitionClips, cameraTracks, activeCamera?.id, playhead]
() =>
autoCut
? getActiveCameraIdAtTime(transitionClips, cameraTracks, activeCamera?.id ?? null, playhead)
: (activeCamera?.id ?? null),
[autoCut, transitionClips, cameraTracks, activeCamera?.id, playhead]
);
const camera = cameras.find((item) => item.id === displayCameraId) ?? activeCamera;
// 小字镜头信息也按播放头解析(与画面渲染同一份 resolveLensAtTime),点击/拖到关键帧时数字同步刷新
@@ -305,16 +265,14 @@ export function LensMonitor({ rightOffset = 12 }: { rightOffset?: number }) {
onPointerCancel={endDrag}
>
{camera && lens ? (
// 机位视角下主视口本身就是机位画面 → 监视器直接镜像它,不再开第二个 Canvas 重渲整个场景
//(省掉一份场景渲染 + 一套景深后处理,而且与主视口逐像素一致)。
// 其它视角(例如导演视角下想同时看机位画面)才需要监视器自己渲染。
viewMode === "camera" ? (
<MonitorMirror sensorAspect={sensorAspect} />
) : (
<Canvas
className="lens-monitor-canvas"
dpr={[1, 1.5]}
gl={{ antialias: true }}
// dpr 与主视口(机位视角)对齐到同一档 [1,2]:否则高分屏(dpr=2)上主视口按 2x 锐利渲染、
// 监视器却只按 1.5x 渲染再放大显示,画面就发虚、和机位视角"显示不一样"。
dpr={[1, 2]}
// preserveDrawingBuffer:机位视角导出就是把这块画布的内容逐帧读进 VideoFrame,
// 不保留绘画缓冲会读到空帧。
gl={{ antialias: true, preserveDrawingBuffer: true }}
camera={{ fov: lens.fov, position: [0, 1, 5] }}
>
<color attach="background" args={[sceneSettings.backgroundColor]} />
@@ -343,7 +301,6 @@ export function LensMonitor({ rightOffset = 12 }: { rightOffset?: number }) {
/>
<SceneRoot editorHelpers={false} />
</Canvas>
)
) : (
<div className="lens-monitor-empty">无激活机位</div>
)}