setFullscreen(false)}
+ />
+
+
+ 镜头监视器
+ {lens ? formatLensInfo(lens) : "无激活机位"}
+
+
+
+ {camera && lens ? (
+
+ {/* 拿到实测尺寸后才挂 Canvas:R3F 需要一个确定的尺寸,否则会按 0 渲染 */}
+ {fullscreenFit.size ? monitorStage : null}
+
+ ) : (
+
无激活机位
+ )}
+
+
+
{dofHint}
+ {bokehDebug.length > 0 ? (
+
+ {bokehDebug.map((item) => (
+
+ {item.key}
+ {`:对焦 ${item.focusDistance.toFixed(1)}m · 光圈 ${item.aperture.toFixed(4)} · 最大虚化 ${item.maxblur.toFixed(
+ 4
+ )} · ${item.width}×${item.height}`}
+
+ ))}
+
+ ) : null}
+
+
+ >,
+ document.body
+ )
+ : null}
+ >
);
}
diff --git a/src/editor/canvas/viewportAspectFrame.test.ts b/src/editor/canvas/viewportAspectFrame.test.ts
index 246e93a..748cdc5 100644
--- a/src/editor/canvas/viewportAspectFrame.test.ts
+++ b/src/editor/canvas/viewportAspectFrame.test.ts
@@ -1,5 +1,11 @@
import { describe, expect, it } from "vitest";
-import { fitFrameWithinViewport } from "./viewportAspectFrame";
+import { fitFrameWithinViewport, fitSensorFrameRect } from "./viewportAspectFrame";
+
+function sensorFrame(width: number, height: number, sensorAspect: number) {
+ const frame = fitSensorFrameRect(width, height, sensorAspect);
+ if (!frame) throw new Error("机位画幅矩形不应为空");
+ return frame;
+}
describe("fitFrameWithinViewport", () => {
it("keeps framed aspect layouts inside the visible safe area between overlay side panels", () => {
@@ -16,3 +22,42 @@ describe("fitFrameWithinViewport", () => {
expect(frame.top + frame.height).toBeLessThanOrEqual(574);
});
});
+
+/**
+ * 机位画幅遮幅:主视口画布比机位画幅宽时,画幅 = 满高 + 水平居中。
+ * 这块矩形必须与镜头监视器 / 视频导出的取景完全重合(它们都按传感器画幅渲染)。
+ */
+describe("fitSensorFrameRect", () => {
+ it("画布比画幅宽时:满高、水平居中、宽 = 高 × 画幅比", () => {
+ // 实测数据:机位视角主视口 1920×919(≈2.09:1)、机位画幅 = 全画幅 36/24
+ const frame = sensorFrame(1920, 919, 36 / 24);
+
+ expect(frame.height).toBeCloseTo(919, 6);
+ expect(frame.width).toBeCloseTo(919 * (36 / 24), 6);
+ expect(frame.left).toBeCloseTo((1920 - 919 * (36 / 24)) / 2, 6);
+ expect(frame.top).toBeCloseTo(0, 6);
+ // 左右各约 14% 是镜头拍不到的区域 —— 这正是「主视口看起来更广」的来源
+ expect((1920 - frame.width) / 2 / 1920).toBeCloseTo(0.141, 3);
+ });
+
+ it("窗口比画幅还窄时:满宽、垂直居中", () => {
+ const frame = sensorFrame(900, 1200, 1.5);
+
+ expect(frame.width).toBeCloseTo(900, 6);
+ expect(frame.height).toBeCloseTo(600, 6);
+ expect(frame.top).toBeCloseTo(300, 6);
+ expect(frame.left).toBeCloseTo(0, 6);
+ });
+
+ it("矩形宽高比严丝合缝等于画幅比(保证遮幅 = 监视器取景)", () => {
+ const frame = sensorFrame(1920, 919, 36 / 24);
+
+ expect(frame.width / frame.height).toBeCloseTo(36 / 24, 8);
+ });
+
+ it("尺寸非法时返回 null,不画遮幅", () => {
+ expect(fitSensorFrameRect(0, 919, 1.5)).toBeNull();
+ expect(fitSensorFrameRect(1920, 0, 1.5)).toBeNull();
+ expect(fitSensorFrameRect(1920, 919, 0)).toBeNull();
+ });
+});
diff --git a/src/editor/canvas/viewportAspectFrame.ts b/src/editor/canvas/viewportAspectFrame.ts
index 3e3f1d0..46123a1 100644
--- a/src/editor/canvas/viewportAspectFrame.ts
+++ b/src/editor/canvas/viewportAspectFrame.ts
@@ -67,3 +67,29 @@ export function getViewportAspectFrameRect(
return fitFrameWithinViewport(width, height, ratioValue, bottomPadding, safeAreaInsets);
}
+
+/**
+ * 机位画幅矩形:这台机位「实际拍到的范围」在画布里的位置。
+ *
+ * 机位视角下主视口画布按「整窗宽高比」渲染(相机 aspect 跟着画布走),而机位画幅由传感器决定
+ * (全画幅 36×24 = 1.5),通常比画布更窄。相机用垂直 FOV,所以画幅 = 满高、水平居中、
+ * 宽度 = 高度 × 画幅比;窗口比画幅还窄时反过来取满宽。这块矩形正是镜头监视器 / 视频导出 /
+ * 截图裁剪的同一块范围。
+ */
+export function fitSensorFrameRect(
+ width: number,
+ height: number,
+ sensorAspect: number
+): ViewportFrameRect | null {
+ if (!(width > 0) || !(height > 0) || !(sensorAspect > 0)) return null;
+
+ const frameWidth = Math.min(width, height * sensorAspect);
+ const frameHeight = Math.min(height, width / sensorAspect);
+
+ return {
+ width: frameWidth,
+ height: frameHeight,
+ left: (width - frameWidth) / 2,
+ top: (height - frameHeight) / 2,
+ };
+}
diff --git a/src/editor/canvas/viewportBokeh.test.ts b/src/editor/canvas/viewportBokeh.test.ts
new file mode 100644
index 0000000..4a74efa
--- /dev/null
+++ b/src/editor/canvas/viewportBokeh.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from "vitest";
+import { resolveBokehMaxBlur } from "./viewportBokeh";
+
+const FULL_FRAME_ASPECT = 36 / 24; // 全画幅 1.5
+
+/**
+ * BokehShader 的模糊像素半径 = maxblur × 渲染宽度,而主体像素尺寸 ∝ 渲染高度,
+ * 所以「虚化相对主体」正比于画布宽高比。这里用该比值守住「与视口宽窄 / 分辨率无关」的契约。
+ */
+function blurOverSubjectHeight(width: number, height: number, sensorAspect: number) {
+ return (resolveBokehMaxBlur(0.012, width, height, sensorAspect) * width) / height;
+}
+
+it("监视器 / 视频导出用的传感器画幅(宽高比 = 画幅比)保持原值不变", () => {
+ expect(resolveBokehMaxBlur(0.012, 1200, 800, FULL_FRAME_ASPECT)).toBeCloseTo(0.012, 8);
+ expect(resolveBokehMaxBlur(0.012, 318, 212, FULL_FRAME_ASPECT)).toBeCloseTo(0.012, 3);
+});
+
+it("主视口这种更宽的画布按画幅归一化:虚化相对主体的强度与监视器一致", () => {
+ // 实测截图:机位视角 1080×517(2.09)、监视器 318×211(1.51)
+ const mainViewport = blurOverSubjectHeight(1080, 517, FULL_FRAME_ASPECT);
+ const monitor = blurOverSubjectHeight(318, 211, FULL_FRAME_ASPECT);
+
+ expect(mainViewport).toBeCloseTo(monitor, 8);
+ // 归一化会显著减弱宽画布的虚化(约 0.72 倍),这正是修掉「视口比监视器糊」的量
+ expect(resolveBokehMaxBlur(0.012, 1080, 517, FULL_FRAME_ASPECT)).toBeCloseTo(0.012 * 0.718, 3);
+});
+
+it("与分辨率无关:同一宽高比下大小画布得到相同的虚化占比", () => {
+ expect(blurOverSubjectHeight(2160, 1034, FULL_FRAME_ASPECT)).toBeCloseTo(
+ blurOverSubjectHeight(1080, 517, FULL_FRAME_ASPECT),
+ 8
+ );
+});
+
+it("尺寸非法时回退为原始 maxblur,避免除零把画面糊死", () => {
+ expect(resolveBokehMaxBlur(0.012, 0, 0, FULL_FRAME_ASPECT)).toBe(0.012);
+ expect(resolveBokehMaxBlur(0.012, 100, 0, FULL_FRAME_ASPECT)).toBe(0.012);
+ expect(resolveBokehMaxBlur(0.012, 100, 100, 0)).toBe(0.012);
+});
diff --git a/src/editor/canvas/viewportBokeh.tsx b/src/editor/canvas/viewportBokeh.tsx
index f9de2b0..4780b1a 100644
--- a/src/editor/canvas/viewportBokeh.tsx
+++ b/src/editor/canvas/viewportBokeh.tsx
@@ -40,10 +40,15 @@ function ensureBokehState(
camera: PerspectiveCamera
): BokehState | null {
const { width, height, pixelRatio } = readSize(gl);
- // 景深后处理要渲染两遍场景(颜色 + 深度)再加 41 抽样合成,是实时预览里最贵的一步。
- // 高 DPI 屏上 gl 的 pixelRatio 可能是 2,像素量直接翻 4 倍,而画面本来就是虚化的 ——
- // 实时预览把合成器像素比压到 1(导出走的是 gl.setPixelRatio(1) + 导出尺寸,不受影响,仍是满画质)。
- const effectivePixelRatio = Math.min(pixelRatio, 1);
+ // 合成器按「画布自身的像素比」渲染,上限 2(3x 屏上像素量再翻倍、收益已经很小)。
+ //
+ // 这里此前压到 1,代价是「机位视角比导演视角明显糊」:压到 1 后合成器按 CSS 尺寸渲染,
+ // 画布缓冲区却是 CSS×dpr,于是整幅画面被浏览器放大到设备像素 —— dpr 2 的屏上等于把 1080 宽的
+ // 图拉成 2160,全是软边;而导演视角走的是 gl.render(原生 dpr),边缘锐利。
+ // 更糟的是主视口(dpr)和镜头监视器(min(dpr,1.5))放大倍率不同 —— 同一颗镜头在两处的
+ // 锐度/锯齿观感也对不上(放大倍率大的那侧更糊)。按原生像素比渲染后三处观感才一致。
+ // 导出不受影响:导出前会 gl.setPixelRatio(1) + 按导出尺寸 setSize,本来就是原生分辨率。
+ const effectivePixelRatio = Math.min(pixelRatio, 2);
const existing = bokehCache.get(gl);
if (
existing &&
@@ -78,12 +83,63 @@ function ensureBokehState(
}
}
+/**
+ * 把「按传感器画幅定义的虚化强度」换算成当前渲染尺寸下的 maxblur。
+ *
+ * BokehShader 的抽样偏移是 UV 量:横向 dx·maxblur(占画面宽度),纵向 dy·aspect·maxblur
+ * —— 换算成像素,两个方向都等于 maxblur × 渲染宽度。而取景用的是「垂直 FOV」,
+ * 主体在画面里的像素尺寸只随画面**高度**走。于是:
+ *
+ * 虚化相对主体 = maxblur × 宽 / 主体像素高 ∝ maxblur × (宽 / 高) = maxblur × 画布宽高比
+ *
+ * 也就是说同一颗镜头,画布越宽虚化看起来越强:主视口机位视角是整窗比例(不受限),
+ * 典型 ≈2.1,而镜头监视器 / 视频导出都按传感器画幅(全画幅 1.5)渲染 ——
+ * 同一画面在视口里会比监视器强约 2.1 / 1.5 ≈ 1.4 倍。
+ *
+ * 这里乘上 (高/宽)·传感器宽高比,把模糊半径改成「∝ 画面高度」:等价于始终按传感器画幅
+ * 计算虚化。监视器 / 导出本来就是传感器画幅,系数为 1,观感与改前完全一致;
+ * 只有宽画布的主视口被拉回同一基准,于是「机位视角 = 监视器 = 导出」的虚化占比严格一致,
+ * 且与分辨率、视口宽窄都无关。
+ */
+export function resolveBokehMaxBlur(
+ bokehMaxBlur: number,
+ width: number,
+ height: number,
+ sensorAspect: number
+): number {
+ if (!(width > 0) || !(height > 0) || !(sensorAspect > 0)) return bokehMaxBlur;
+ return bokehMaxBlur * (height / width) * sensorAspect;
+}
+
+/**
+ * 最近一次渲染实际写进 BokehPass 的参数,按调用方 key 记录(主视口 / 监视器 / 导出各一份)。
+ * 用途:核对「同一机位、同一帧,为什么机位视角和监视器虚化不一样」——
+ * 把两边的镜头(对焦 / 光圈 / 最大模糊)和渲染尺寸摆在一起,就能立刻分清差异出在
+ * 「镜头解析(不同机位 / 不同关键帧)」还是「渲染尺寸(画布宽高比 / 像素比)」。
+ */
+export interface BokehDebugSnapshot {
+ key: string;
+ focusDistance: number;
+ aperture: number;
+ maxblur: number;
+ width: number;
+ height: number;
+}
+
+const debugSnapshots = new Map
();
+
+export function getBokehDebugSnapshots(): BokehDebugSnapshot[] {
+ return [...debugSnapshots.values()];
+}
+
/** 用景深后处理渲染一帧;lens 提供对焦距离 / 光圈 / 最大模糊量。 */
export function renderSceneWithBokeh(
gl: WebGLRenderer,
scene: Scene,
camera: PerspectiveCamera,
- lens: ResolvedLens
+ lens: ResolvedLens,
+ /** 诊断用 key:标记这次渲染属于哪个视图(如「机位视角」/「镜头监视器」),见 getBokehDebugSnapshots */
+ debugKey?: string
): void {
const state = ensureBokehState(gl, scene, camera);
if (!state) {
@@ -92,12 +148,30 @@ export function renderSceneWithBokeh(
}
const uniforms = (state.bokeh as unknown as { uniforms?: Record }).uniforms;
+ const aperture = apertureToBokeh(lens.apertureFNumber, lens.focusDistance);
+ // 按传感器画幅归一化:否则宽画布(主视口)的虚化会比监视器 / 导出强「画布宽高比 / 画幅比」倍
+ const maxblur = resolveBokehMaxBlur(
+ lens.bokehMaxBlur,
+ state.width,
+ state.height,
+ lens.sensor.widthMm / lens.sensor.heightMm
+ );
if (uniforms) {
if (uniforms.focus) uniforms.focus.value = lens.focusDistance;
- // aperture 必须带上对焦距离:景深清晰带要随对焦距离平方展开,
- // 否则对焦到远景时清晰带仍只有约 ±1.1m,连远景都糊(详见 apertureToBokeh 注释)。
- if (uniforms.aperture) uniforms.aperture.value = apertureToBokeh(lens.apertureFNumber, lens.focusDistance);
- if (uniforms.maxblur) uniforms.maxblur.value = lens.bokehMaxBlur;
+ // aperture 必须带上对焦距离:景深过渡带要随对焦距离平方展开,
+ // 否则对焦到远景时带宽恒定(默认 f/2.8 约 ±4.2m),连远景都糊(详见 apertureToBokeh 注释)。
+ if (uniforms.aperture) uniforms.aperture.value = aperture;
+ if (uniforms.maxblur) uniforms.maxblur.value = maxblur;
+ }
+ if (debugKey) {
+ debugSnapshots.set(debugKey, {
+ key: debugKey,
+ focusDistance: lens.focusDistance,
+ aperture,
+ maxblur,
+ width: state.width,
+ height: state.height,
+ });
}
state.composer.render();
}
diff --git a/src/editor/panels/CameraPanel.test.tsx b/src/editor/panels/CameraPanel.test.tsx
index 72af9e7..5a06b0a 100644
--- a/src/editor/panels/CameraPanel.test.tsx
+++ b/src/editor/panels/CameraPanel.test.tsx
@@ -125,6 +125,24 @@ it("uses the provided right inspector layout for camera properties", () => {
expect(fovField).toBeInTheDocument();
});
+it("shows the selected subject distance and focuses the camera onto it in one click", async () => {
+ const user = userEvent.setup();
+ // 选中可对焦物体(角色)后,面板直接给出「机位 → 物体」的实测距离
+ useDirectorStore.setState({
+ ...useDirectorStore.getState(),
+ selectedObjectId: "char_default_a",
+ });
+ render();
+
+ // 默认机位 [0, 1.55, 5.4] → 角色焦点 [0, 0.99, 0] ≈ 5.4m
+ expect(screen.getByLabelText("所选物体距离")).toHaveTextContent("所选物体 5.4 m");
+
+ await user.click(screen.getByRole("button", { name: "对焦到所选物体" }));
+
+ const camera = useDirectorStore.getState().project.cameras[0];
+ expect(camera.focusDistance).toBeCloseTo(5.43, 1);
+});
+
it("keeps camera panel tab labels in fixed slots while switching tabs", async () => {
const user = userEvent.setup();
const { container } = render();
diff --git a/src/editor/panels/CameraPanel.tsx b/src/editor/panels/CameraPanel.tsx
index f13221c..024c6b5 100644
--- a/src/editor/panels/CameraPanel.tsx
+++ b/src/editor/panels/CameraPanel.tsx
@@ -17,10 +17,14 @@ import { postDirectorDeskCapturesToHost } from "../io/hostBridge";
import {
DEFAULT_DIRECTOR_CAMERA_VIEW_SNAPSHOT,
getCameraLookRotationFromTarget,
+ getCameraViewSnapshotFromShot,
+ getDepthOfFieldHalfWidth,
getSensorPreset,
+ getWorldCameraViewSnapshot,
computeVerticalFovFromLens,
computeFocalLengthFromFov,
} from "../schema/cameraGeometry";
+import { evaluateCameraViewAtTime } from "../schema/timeline";
import { SENSOR_PRESETS, type SensorPresetId } from "../schema/directorProject";
import { getDirectorObjectFocusTarget, isCameraFocusableObject } from "../schema/cameraTarget";
import type { DirectorCameraCapture } from "../schema/directorProject";
@@ -103,6 +107,9 @@ export function CameraPanel() {
// 面板里的「机位位置 X/Y/Z」也要显示同一套世界坐标,否则场景一缩放 / 旋转,
// 手柄(世界)和数值(局部)就差得越来越多。编辑时把世界值反算回场景局部坐标再存。
const scene = useDirectorStore((state) => state.project.scene);
+ const selectedObjectId = useDirectorStore((state) => state.selectedObjectId);
+ const transitionClips = useDirectorStore((state) => state.project.timeline.transitionClips);
+ const globalTransition = useDirectorStore((state) => state.project.timeline.cameraTransition);
const sceneMatrix = useMemo(() => getSceneGroupMatrix(scene), [scene]);
const sceneMatrixInverse = useMemo(() => sceneMatrix.clone().invert(), [sceneMatrix]);
const localToWorldAxis = (local: [number, number, number], axis: 0 | 1 | 2): number =>
@@ -112,6 +119,34 @@ export function CameraPanel() {
if (!camera) return null;
const currentCamera = camera;
+ // 当前时刻「真正渲染」的机位世界位姿(含运镜关键帧 / 多机位过渡)——与主视口、监视器、导出同源。
+ // 「对焦到所选物体」必须按这个位姿量距离:有运镜关键帧时,基础机位的位置并不是画面里的位置,
+ // 按基础机位量出来的距离会把对焦对到别处 —— 看起来就是「明明对焦了还是糊」。
+ const activeCameraWorldView = (() => {
+ const getStaticPose = (cameraId: string) => {
+ const shot = cameras.find((item) => item.id === cameraId);
+ return shot ? getWorldCameraViewSnapshot(getCameraViewSnapshotFromShot(shot), scene) : null;
+ };
+ const view = evaluateCameraViewAtTime(
+ transitionClips,
+ cameraTracks,
+ globalTransition,
+ currentCamera.id,
+ playhead,
+ getStaticPose
+ );
+ const localView = view
+ ? { fov: view.pose.fov, position: view.pose.position, lookRotation: view.pose.lookRotation }
+ : getCameraViewSnapshotFromShot(currentCamera);
+ return getWorldCameraViewSnapshot(localView, scene);
+ })();
+ const selectedFocusObject = objects.find((item) => item.id === selectedObjectId) ?? null;
+ const selectedFocusDistance =
+ selectedFocusObject && isCameraFocusableObject(selectedFocusObject)
+ ? new Vector3(...activeCameraWorldView.position).distanceTo(
+ new Vector3(...getDirectorObjectFocusTarget(selectedFocusObject))
+ )
+ : null;
const captures = useMemo(() => currentCamera.captures ?? [], [currentCamera.captures]);
const cameraCaptureGroups = useMemo(
() =>
@@ -471,6 +506,8 @@ export function CameraPanel() {
* 按播放头解析(resolveLensAtTime):无运镜轨道回落机位静态值;有轨道则显示该时刻插值出的镜头参数,
* 点击/拖到关键帧即显示那帧的镜头数值。 */
const lens = resolveLensAtTime(currentCamera, playhead, cameraTracks);
+ // 与渲染同一套换算:超过这个半径的物体一律吃满最大模糊(画面"整片糊"就是没有主体落在带宽内)
+ const depthOfFieldHalfWidth = getDepthOfFieldHalfWidth(lens);
function handleFovChange(value: string) {
const fov = Number(value);
@@ -504,6 +541,23 @@ export function CameraPanel() {
updateCameraLensAtTime(currentCamera.id, { focusDistance }, playhead);
}
+ /**
+ * 一键对焦到所选物体:把对焦距离设成「当前机位世界位姿 → 所选物体」的实测距离。
+ *
+ * 走 allKeyframes:该机位有运镜轨道时,画面用的是关键帧插值出来的镜头,只改最近一帧的话,
+ * 当前时刻的对焦距离仍然是插值结果,设了也不会立刻生效("对焦到物体上了还是糊")。
+ * 这是一次性动作,语义就是「这台机位对焦在这个物体上」,所以整条轨道统一改掉。
+ */
+ function handleFocusSelectedObject() {
+ if (selectedFocusDistance === null) return;
+ updateCameraLensAtTime(
+ currentCamera.id,
+ { focusDistance: Number(selectedFocusDistance.toFixed(2)) },
+ playhead,
+ { allKeyframes: true }
+ );
+ }
+
function handleBokehChange(value: string) {
const bokehMaxBlur = Number(value);
if (!Number.isFinite(bokehMaxBlur)) return;
@@ -962,6 +1016,24 @@ export function CameraPanel() {
value={lens.bokehMaxBlur}
onValueChange={handleBokehChange}
/>
+ {/* 把「对焦距离 + 光圈 + 景深强度」换算成实际带宽,避免只能靠试:
+ 主体不在带宽内 → 整片糊;想两个主体都清楚就把光圈 f 值调大。 */}
+
+ {Number.isFinite(depthOfFieldHalfWidth)
+ ? `对焦 ${lens.focusDistance.toFixed(1)} m · ±${depthOfFieldHalfWidth.toFixed(1)} m 以外即最大虚化`
+ : "景深强度为 0 · 全画面清晰"}
+
+ {/* 主体距离直接给出来,不用再靠估:选中角色 / 道具即可一键把对焦对上去 */}
+ {selectedFocusDistance !== null ? (
+
+
+ 所选物体 {selectedFocusDistance.toFixed(1)} m
+
+
+
+ ) : null}