From f778f059574f4ddbb6d1e936716ae61258cfc77e Mon Sep 17 00:00:00 2001 From: TongTongStudio Date: Sun, 20 Sep 2026 08:39:07 +0800 Subject: [PATCH] =?UTF-8?q?refactor(io):=20=E6=9C=BA=E4=BD=8D=E8=A7=86?= =?UTF-8?q?=E8=A7=92=E5=AF=BC=E5=87=BA=E6=94=B9=E7=94=A8=E7=9B=91=E8=A7=86?= =?UTF-8?q?=E5=99=A8=E7=94=BB=E5=B8=83=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/editor/canvas/LensMonitor.tsx | 113 ++++++------------ src/editor/canvas/viewportBokeh.tsx | 61 ++++++++-- src/editor/io/monitorRenderBridge.ts | 26 ++++ src/editor/io/videoExport.test.ts | 6 + src/editor/io/videoExport.ts | 43 +++++-- .../runtime/ActiveCameraPlaybackSync.tsx | 38 +++++- src/styles/index.css | 5 + 7 files changed, 191 insertions(+), 101 deletions(-) create mode 100644 src/editor/io/monitorRenderBridge.ts diff --git a/src/editor/canvas/LensMonitor.tsx b/src/editor/canvas/LensMonitor.tsx index 6d3e173..479ea7f 100644 --- a/src/editor/canvas/LensMonitor.tsx +++ b/src/editor/canvas/LensMonitor.tsx @@ -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(null); - - useEffect(() => { - let frame = 0; - const draw = () => { - frame = requestAnimationFrame(draw); - const target = canvasRef.current; - const source = document.querySelector(".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 ; -} - 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" ? ( - - ) : ( @@ -343,7 +301,6 @@ export function LensMonitor({ rightOffset = 12 }: { rightOffset?: number }) { /> - ) ) : (
无激活机位
)} diff --git a/src/editor/canvas/viewportBokeh.tsx b/src/editor/canvas/viewportBokeh.tsx index f9de2b0..067ea35 100644 --- a/src/editor/canvas/viewportBokeh.tsx +++ b/src/editor/canvas/viewportBokeh.tsx @@ -1,4 +1,4 @@ -import { Vector2, type PerspectiveCamera, type Scene, type WebGLRenderer } from "three"; +import { Vector2, Vector4, type PerspectiveCamera, type Scene, type WebGLRenderer } from "three"; import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js"; import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js"; import { BokehPass } from "three/examples/jsm/postprocessing/BokehPass.js"; @@ -37,13 +37,13 @@ function readSize(gl: WebGLRenderer): { width: number; height: number; pixelRati function ensureBokehState( gl: WebGLRenderer, scene: Scene, - camera: PerspectiveCamera + camera: PerspectiveCamera, + sizeOverride?: { width: number; height: number; pixelRatio: number } ): BokehState | null { - const { width, height, pixelRatio } = readSize(gl); - // 景深后处理要渲染两遍场景(颜色 + 深度)再加 41 抽样合成,是实时预览里最贵的一步。 - // 高 DPI 屏上 gl 的 pixelRatio 可能是 2,像素量直接翻 4 倍,而画面本来就是虚化的 —— - // 实时预览把合成器像素比压到 1(导出走的是 gl.setPixelRatio(1) + 导出尺寸,不受影响,仍是满画质)。 - const effectivePixelRatio = Math.min(pixelRatio, 1); + const { width, height, pixelRatio } = sizeOverride ?? readSize(gl); + // 合成器必须按画布真实分辨率(CSS 尺寸 × pixelRatio = drawing buffer)渲染: + // 如果只按 CSS 尺寸渲染,结果会被放大 dpr 倍,整幅画面发虚(实测主视口糊成一团就是这个原因), + // 而且不同画布 dpr 不同(主视口 dpr 更高、监视器被夹到 1.5)时两边糊的程度还不一样。 const existing = bokehCache.get(gl); if ( existing && @@ -51,14 +51,14 @@ function ensureBokehState( existing.camera === camera && existing.width === width && existing.height === height && - existing.pixelRatio === effectivePixelRatio + existing.pixelRatio === pixelRatio ) { return existing; } try { const composer = new EffectComposer(gl); - composer.setPixelRatio(effectivePixelRatio); + composer.setPixelRatio(pixelRatio); composer.setSize(width, height); composer.addPass(new RenderPass(scene, camera)); const bokeh = new BokehPass(scene, camera, { @@ -69,7 +69,7 @@ function ensureBokehState( composer.addPass(bokeh); // OutputPass 负责把线性结果做色调映射 + sRGB 转换,否则经后处理的画面会偏暗/发灰 composer.addPass(new OutputPass()); - const next: BokehState = { composer, bokeh, width, height, pixelRatio: effectivePixelRatio, scene, camera }; + const next: BokehState = { composer, bokeh, width, height, pixelRatio, scene, camera }; bokehCache.set(gl, next); return next; } catch { @@ -78,14 +78,27 @@ function ensureBokehState( } } -/** 用景深后处理渲染一帧;lens 提供对焦距离 / 光圈 / 最大模糊量。 */ +/** + * 用景深后处理渲染一帧;lens 提供对焦距离 / 光圈 / 最大模糊量。 + * viewportRect(CSS 像素)用于「机位视角」:那里把机位画面按传感器画幅居中遮幅渲染。 + * 监视器 / 导出本就按传感器画幅渲染;机位视角若按整个视口渲染,相机投影画幅更宽 → + * 水平视场角更大、物体被压得更窄,而虚化半径按画面宽度的固定比例(UV)不变 → + * 物体相对于自身看起来就更糊。传入 viewportRect 后,合成器按矩形(传感器画幅)尺寸渲染、 + * 并经 setViewport/setScissor 把结果落到画布中央,让机位视角的取景 / 虚化与监视器逐像素一致。 + */ export function renderSceneWithBokeh( gl: WebGLRenderer, scene: Scene, camera: PerspectiveCamera, - lens: ResolvedLens + lens: ResolvedLens, + viewportRect?: { x: number; y: number; width: number; height: number } ): void { - const state = ensureBokehState(gl, scene, camera); + const state = ensureBokehState( + gl, + scene, + camera, + viewportRect ? { width: viewportRect.width, height: viewportRect.height, pixelRatio: gl.getPixelRatio() } : undefined + ); if (!state) { gl.render(scene, camera); return; @@ -98,6 +111,28 @@ export function renderSceneWithBokeh( // 否则对焦到远景时清晰带仍只有约 ±1.1m,连远景都糊(详见 apertureToBokeh 注释)。 if (uniforms.aperture) uniforms.aperture.value = apertureToBokeh(lens.apertureFNumber, lens.focusDistance); if (uniforms.maxblur) uniforms.maxblur.value = lens.bokehMaxBlur; + // bokeh 的 aspect 必须等于渲染画幅(合成器尺寸的宽高比):纵向模糊偏移是 0.4 * aspect * dofblur, + // 画幅对了,虚化形状 / 强弱才和监视器一致。合成器是缓存复用的,每帧显式写避免残留。 + if (uniforms.aspect) uniforms.aspect.value = state.width / state.height; + } + + // 有遮幅矩形时,把最终结果落到画布中央的矩形里(中间 pass 仍渲染到完整目标,不受影响)。 + const pr = gl.getPixelRatio(); + const prevViewport = new Vector4(); + const prevScissor = new Vector4(); + let prevScissorTest = false; + if (viewportRect) { + gl.getViewport(prevViewport); + gl.getScissor(prevScissor); + prevScissorTest = gl.getScissorTest(); + gl.setViewport(viewportRect.x * pr, viewportRect.y * pr, viewportRect.width * pr, viewportRect.height * pr); + gl.setScissor(viewportRect.x * pr, viewportRect.y * pr, viewportRect.width * pr, viewportRect.height * pr); + gl.setScissorTest(true); } state.composer.render(); + if (viewportRect) { + gl.setScissorTest(prevScissorTest); + gl.setScissor(prevScissor); + gl.setViewport(prevViewport); + } } diff --git a/src/editor/io/monitorRenderBridge.ts b/src/editor/io/monitorRenderBridge.ts new file mode 100644 index 0000000..0e1bb27 --- /dev/null +++ b/src/editor/io/monitorRenderBridge.ts @@ -0,0 +1,26 @@ +import type { PerspectiveCamera, Scene, WebGLRenderer } from "three"; + +/** + * 把「镜头监视器」那块独立 Canvas 的渲染三件套(renderer / scene / camera)暴露给视频导出。 + * + * 机位视角导出直接在这块画布的 gl 上、用它自己的 scene / camera、按导出分辨率逐帧渲染 —— + * 与监视器物理上就是同一份渲染(同一颗 gl、同一个场景、同一套景深管线), + * 天然逐像素一致,也省得把主视口的场景辅助元素再隐藏一遍(监视器场景本就只有成片内容)。 + * + * 模式与 videoExportBridge / captureBridge 一致:Canvas 内的桥组件注册,导出方读取。 + */ +export interface MonitorRenderContext { + gl: WebGLRenderer; + scene: Scene; + camera: PerspectiveCamera; +} + +let monitorRenderContext: MonitorRenderContext | null = null; + +export function setMonitorRenderContext(context: MonitorRenderContext | null) { + monitorRenderContext = context; +} + +export function getMonitorRenderContext(): MonitorRenderContext | null { + return monitorRenderContext; +} diff --git a/src/editor/io/videoExport.test.ts b/src/editor/io/videoExport.test.ts index 820c1b9..afc9b07 100644 --- a/src/editor/io/videoExport.test.ts +++ b/src/editor/io/videoExport.test.ts @@ -22,6 +22,12 @@ vi.mock("./videoExportBridge", () => ({ getVideoExportContext: () => contextRef.current, })); +// 机位视角导出改为「从监视器那块画布出片」,这里让监视器渲染上下文同样指向这套假上下文: +// 于是机位视角导出用的就是同一颗(被记录的)gl / scene / camera。 +vi.mock("./monitorRenderBridge", () => ({ + getMonitorRenderContext: () => contextRef.current, +})); + // 相机视角导出会走 renderSceneWithBokeh(真实 EffectComposer / BokehPass 在 jsdom 下无 WebGL)。 // 桩里直接委托给 gl.render,既能避开 WebGL,又保留「记录每帧相机位姿」的测试意图 // (createFakeBridge 只在 gl.render 时把相机位姿写入 bridge.rendered)。 diff --git a/src/editor/io/videoExport.ts b/src/editor/io/videoExport.ts index 0596824..34230a4 100644 --- a/src/editor/io/videoExport.ts +++ b/src/editor/io/videoExport.ts @@ -5,6 +5,7 @@ import { useDirectorStore } from "../store/directorStore"; import { usePlaybackStore } from "../runtime/playbackStore"; import type { Scene } from "three"; import { getVideoExportContext } from "./videoExportBridge"; +import { getMonitorRenderContext, type MonitorRenderContext } from "./monitorRenderBridge"; import { hideViewportCaptureHelpers, withViewportCaptureHelpersHidden } from "../canvas/viewportCapture"; import { applyActiveCameraViewAtTime, applyTimelineTransformsAtTime, resolveLensAtTime } from "../runtime/playbackTransforms"; import { getCameraRenderQuaternion, getCameraViewSnapshotFromShot, getWorldCameraViewSnapshot, resolveLens, type CameraViewSnapshot } from "../schema/cameraGeometry"; @@ -56,6 +57,21 @@ function waitNextFrame(): Promise { ); } +/** + * 机位视角导出从「镜头监视器」那块画布出片:需要等它进入自渲染模式并注册好渲染上下文。 + * 监视器的自渲染 Canvas 是条件挂载的(机位视角平时显示的是主视口镜像), + * 导出标志置位 + React 重渲染 + WebGL 上下文就绪需要几帧,这里轮询等待。 + */ +async function waitForMonitorRenderContext(timeoutMs = 4000): Promise { + const deadline = performance.now() + timeoutMs; + while (performance.now() < deadline) { + const context = getMonitorRenderContext(); + if (context) return context; + await waitNextFrame(); + } + return getMonitorRenderContext(); +} + /** * 机位没有运镜轨道时的兜底:把「这个机位自己的视角」直接写到导出相机上。 * @@ -149,10 +165,17 @@ async function pickVideoEncoderConfig( * - H.264 编码进 MP4(Chrome / Electron 均支持) */ async function exportVideoFrameByFrame(view: VideoExportView): Promise { - const bridge = getVideoExportContext(); - if (!bridge) throw new Error("找不到视口渲染上下文"); + // 机位视角「直接用监视器那块画布出片」:同一颗 gl / 同一个场景(本就只有成片内容、没有编辑器辅助)/ + // 同一颗相机 / 同一套景深管线 —— 导出的画面与监视器所见就是同一份渲染,天然逐像素一致。 + // 上帝视角仍走主视口(监视器只显示机位画面)。 + const bridge = view === "camera" ? await waitForMonitorRenderContext() : getVideoExportContext(); + if (!bridge) { + throw new Error(view === "camera" ? "找不到镜头监视器的渲染上下文" : "找不到视口渲染上下文"); + } const { gl, scene, camera } = bridge; const canvas = gl.domElement; + // 机位视角的渲染源是监视器自己那块画布,不必去动主视口的视角 + const switchesViewportView = view !== "camera"; const directorStore = useDirectorStore.getState(); const exportSettings = directorStore.project.exportSettings; @@ -213,7 +236,7 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise { // 切到导出状态:固定分辨率渲染(不改 CSS 尺寸,canvas 加 object-fit: contain 保持显示不变形) usePlaybackStore.getState().setPlaying(false); - directorStore.setViewMode(view); + if (switchesViewportView) directorStore.setViewMode(view); canvas.classList.add("is-video-exporting"); gl.setPixelRatio(1); gl.setSize(width, height, false); @@ -235,7 +258,8 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise { if (encodeError) throw encodeError; const time = Math.min(frameIndex / fps, duration); // 相机可能因视角切换被替换,每帧从桥取最新;并保证导出画面比例 - const frameCamera = getVideoExportContext()?.camera ?? camera; + const frameCamera = + (view === "camera" ? getMonitorRenderContext()?.camera : getVideoExportContext()?.camera) ?? camera; frameCamera.aspect = width / height; frameCamera.updateProjectionMatrix(); @@ -349,11 +373,14 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise { // - aspect 两颗都要还,否则切回导演视角后画面比例还停在导出的 16:9; // - 位姿只在「导出用的就是导出前那颗相机」时还,否则等于把导演相机的位姿 // 写到机位相机上,机位视角就会被带歪。 - const restoreCamera = getVideoExportContext()?.camera ?? camera; + const restoreCamera = + (view === "camera" ? getMonitorRenderContext()?.camera : getVideoExportContext()?.camera) ?? camera; restoreCamera.aspect = previousAspect; restoreCamera.updateProjectionMatrix(); - camera.aspect = previousAspect; - camera.updateProjectionMatrix(); + if (restoreCamera !== camera) { + camera.aspect = previousAspect; + camera.updateProjectionMatrix(); + } if (restoreCamera === camera) { restoreCamera.position.copy(previousCameraPosition); @@ -362,7 +389,7 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise { restoreCamera.updateProjectionMatrix(); } - directorStore.setViewMode(previousViewMode); + if (switchesViewportView) directorStore.setViewMode(previousViewMode); usePlaybackStore.getState().setPlayhead(previousPlayhead); } } diff --git a/src/editor/runtime/ActiveCameraPlaybackSync.tsx b/src/editor/runtime/ActiveCameraPlaybackSync.tsx index ce090b5..d6eddc6 100644 --- a/src/editor/runtime/ActiveCameraPlaybackSync.tsx +++ b/src/editor/runtime/ActiveCameraPlaybackSync.tsx @@ -1,11 +1,12 @@ import { useRef } from "react"; import { useFrame, useThree } from "@react-three/fiber"; -import { PerspectiveCamera as ThreePerspectiveCamera } from "three"; +import { Color, PerspectiveCamera as ThreePerspectiveCamera } from "three"; import { useDirectorStore } from "../store/directorStore"; import { usePlaybackStore } from "./playbackStore"; import { applyActiveCameraViewAtTime, resolveLensAtTime } from "./playbackTransforms"; import { getActiveCameraIdAtTime } from "../schema/timeline"; import { renderSceneWithBokeh } from "../canvas/viewportBokeh"; +import { withViewportCaptureHelpersHidden } from "../canvas/viewportCapture"; /** * 播放 / 导出 / 拖动标尺 scrub 时按关键帧自动切机位:当前应显示的机位由所有相机轨道里 @@ -24,6 +25,8 @@ import { renderSceneWithBokeh } from "../canvas/viewportBokeh"; * 用 priority=1 的 useFrame 接管渲染循环(priority>0 会关掉 r3f 的自动渲染,必须自己渲染)。 * 挂在 内。 */ +const clearColor = new Color(); + export function ActiveCameraPlaybackSync({ activeCameraId }: { activeCameraId: string | null }) { const { gl, scene } = useThree(); const camera = useThree((state) => state.camera) as ThreePerspectiveCamera; @@ -88,7 +91,38 @@ export function ActiveCameraPlaybackSync({ activeCameraId }: { activeCameraId: s transition: cameraTransition, fallbackCameraId: activeCameraId, }); - renderSceneWithBokeh(gl, scene, camera, resolveLensAtTime(shot, playhead, cameraTracks)); + // 与镜头监视器严格一致:机位视角也按「传感器画幅」渲染。监视器(LensMonitorCamera)会把渲染相机的 + // aspect 设成传感器比例;机位视角若按整个视口渲染,相机投影画幅更宽 → 水平视场角更大、 + // 物体被压得更窄,而虚化半径按画面宽度的固定比例(UV)不变 → 物体相对自身看起来更糊。 + // 所以这里把相机 aspect 设成传感器比例、把机位画面居中遮幅渲染(渲染矩形也是传感器画幅), + // 让取景 / 虚化与监视器逐像素一致。 + const lens = resolveLensAtTime(shot, playhead, cameraTracks); + const sensorAspect = lens.sensor.widthMm / lens.sensor.heightMm; + if (Math.abs(camera.aspect - sensorAspect) > 1e-4) { + camera.aspect = sensorAspect; + camera.updateProjectionMatrix(); + } + // 计算画布中央传感器画幅的矩形(CSS 像素)。 + const pr = gl.getPixelRatio(); + const cssW = gl.domElement.width / pr; + const cssH = gl.domElement.height / pr; + const canvasAspect = cssW / cssH; + let rectW = cssW; + let rectH = cssH; + if (canvasAspect > sensorAspect) rectW = cssH * sensorAspect; + else rectH = cssW / sensorAspect; + const rect = { x: (cssW - rectW) / 2, y: (cssH - rectH) / 2, width: rectW, height: rectH }; + // 先把整个画布按背景色清成遮幅(黑边 / 背景色),再把机位画面渲进矩形里。 + gl.setClearColor(clearColor.set(projectScene.backgroundColor), 1); + gl.setScissorTest(false); + gl.setViewport(0, 0, gl.domElement.width, gl.domElement.height); + gl.clear(true, true, false); + // 与镜头监视器 / 导出对齐:渲染前把编辑器辅助(视口网格、gizmo 红绿蓝轴、轨迹点 / 曲线、 + // 地面坐标轴)藏起来 —— 监视器是 SceneRoot editorHelpers={false},一个都没有;这些东西留在 + // 机位视角里既多出监视器没有的内容,又会写进 bokeh 的深度缓冲污染虚化(清晰平面 / 强弱对不上)。 + withViewportCaptureHelpersHidden(scene, () => { + renderSceneWithBokeh(gl, scene, camera, lens, rect); + }); return; } } diff --git a/src/styles/index.css b/src/styles/index.css index 3dd63f0..6455c6d 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -3618,6 +3618,11 @@ button.inspector-dropdown-option.is-selected { object-fit: contain; } +/* 机位视角导出走监视器那块画布:同样在导出期间切到导出分辨率,保持比例不变形 */ +.lens-monitor-canvas.is-video-exporting { + object-fit: contain; +} + /* ---- 工具条:左=时间信息,中=播放控制 + 关键帧操作,右=导出 ---- 三栏等宽(1fr auto 1fr):中间一组永远落在整条工具条的正中, 不会因为左右两组宽度不同而偏移。 */