Compare commits
1 Commits
fix_blur
...
f778f05957
| Author | SHA1 | Date | |
|---|---|---|---|
| f778f05957 |
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
26
src/editor/io/monitorRenderBridge.ts
Normal file
26
src/editor/io/monitorRenderBridge.ts
Normal 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;
|
||||
}
|
||||
@@ -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)。
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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):中间一组永远落在整条工具条的正中,
|
||||
不会因为左右两组宽度不同而偏移。 */
|
||||
|
||||
Reference in New Issue
Block a user