Compare commits

...

1 Commits

Author SHA1 Message Date
f778f05957 refactor(io): 机位视角导出改用监视器画布渲染 2026-09-20 08:39:07 +08:00
7 changed files with 191 additions and 101 deletions

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>
)}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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)。

View File

@@ -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<void> {
);
}
/**
* 机位视角导出从「镜头监视器」那块画布出片:需要等它进入自渲染模式并注册好渲染上下文。
* 监视器的自渲染 Canvas 是条件挂载的(机位视角平时显示的是主视口镜像),
* 导出标志置位 + React 重渲染 + WebGL 上下文就绪需要几帧,这里轮询等待。
*/
async function waitForMonitorRenderContext(timeoutMs = 4000): Promise<MonitorRenderContext | null> {
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<void> {
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<void> {
// 切到导出状态:固定分辨率渲染(不改 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<void> {
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<void> {
// - 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<void> {
restoreCamera.updateProjectionMatrix();
}
directorStore.setViewMode(previousViewMode);
if (switchesViewportView) directorStore.setViewMode(previousViewMode);
usePlaybackStore.getState().setPlayhead(previousPlayhead);
}
}

View File

@@ -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 的自动渲染,必须自己渲染)。
* 挂在 <Canvas> 内。
*/
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;
}
}

View File

@@ -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):中间一组永远落在整条工具条的正中,
不会因为左右两组宽度不同而偏移。 */