Compare commits

...

2 Commits

16 changed files with 885 additions and 144 deletions

View File

@@ -20,7 +20,7 @@ beforeEach(() => {
it("renders the director desk header and view mode switch", () => {
const { container } = render(<App />);
expect(screen.getByText("3D导演台")).toBeInTheDocument();
expect(screen.queryByText("3D导演台")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "导演视角" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "机位视角" })).toBeInTheDocument();
expect(container.querySelector(".top-bar-center .mode-toggle")).toBeInTheDocument();

View File

@@ -79,9 +79,9 @@ export default function App() {
return (
<div className="app-shell">
<header className="top-bar">
<div className="top-bar-left">
<h1 className="top-bar-title">3D导演台</h1>
</div>
{/* 占位:顶栏是「左栏宽 / 中间 1fr / 右栏宽」三列栅格,
少了这块,中间的视角切换会落到左列里去,左对齐而不是居中。 */}
<div className="top-bar-left" />
<div className="top-bar-center">
<div className="mode-toggle ui-segmented" role="group" aria-label="视角切换">
<button

View File

@@ -45,7 +45,11 @@ import { LensMonitor } from "./LensMonitor";
import { renderSceneWithBokeh } from "./viewportBokeh";
import { ViewportBackground } from "./ViewportBackground";
import { ViewportToolbar } from "./ViewportToolbar";
import { getViewportAspectFrameRect, type ViewportSafeAreaInsets } from "./viewportAspectFrame";
import {
getViewportAspectFrameRect,
resolveCameraViewRenderRect,
type ViewportSafeAreaInsets,
} from "./viewportAspectFrame";
/**
* 导演视角的初始位姿:OrbitControls 需要一个轨道中心点,所以这里存的是「注视点」;
@@ -387,8 +391,7 @@ function drawViewportCaptureLabels({
frameRect,
heightScale,
labels,
viewportHeight,
viewportWidth,
projectionRect,
widthScale,
}: {
camera: ThreePerspectiveCamera;
@@ -396,8 +399,11 @@ function drawViewportCaptureLabels({
frameRect: ViewportCaptureFrameRect;
heightScale: number;
labels: ViewportCaptureLabel[];
viewportHeight: number;
viewportWidth: number;
/**
* 这帧 3D 场景实际渲染到画布的哪一块(CSS 像素,左上角原点)。
* 导演视角是整块画布;机位视角是画幅框(无画幅框时传感器画幅遮幅),所以标签必须按这块做 NDC → 像素换算。
*/
projectionRect: ViewportCaptureFrameRect;
widthScale: number;
}) {
const drawingContext = context as CanvasRenderingContext2D & {
@@ -423,8 +429,8 @@ function drawViewportCaptureLabels({
const projected = label.worldPosition.clone().project(camera);
if (projected.z < -1 || projected.z > 1) return;
const viewportX = (projected.x * 0.5 + 0.5) * viewportWidth;
const viewportY = (-projected.y * 0.5 + 0.5) * viewportHeight;
const viewportX = projectionRect.left + (projected.x * 0.5 + 0.5) * projectionRect.width;
const viewportY = projectionRect.top + (-projected.y * 0.5 + 0.5) * projectionRect.height;
const x = (viewportX - frameRect.left) * widthScale;
const y = (viewportY - frameRect.top) * heightScale;
const textWidth = context.measureText(label.text).width;
@@ -451,17 +457,18 @@ function captureViewportCanvas(
captureLabels?: {
camera: ThreePerspectiveCamera;
labels: ViewportCaptureLabel[];
}
},
/**
* 机位视角:这帧 3D 场景渲染到画布里的哪一块(CSS 像素,左上角原点)。
* 传了它就按它裁切(截图与视口机位画面逐像素一致);不传则按画幅框裁切(导演视角的既有行为)。
*/
renderViewportRect?: ViewportCaptureFrameRect
) {
const viewportWidth = canvas.clientWidth || canvas.width;
const viewportHeight = canvas.clientHeight || canvas.height;
const frameRect = getViewportAspectFrameRect(
aspectRatio,
viewportWidth,
viewportHeight,
bottomPadding,
safeAreaInsets
);
const frameRect =
renderViewportRect ??
getViewportAspectFrameRect(aspectRatio, viewportWidth, viewportHeight, bottomPadding, safeAreaInsets);
const labels = captureLabels?.labels ?? [];
if (!frameRect && labels.length === 0) {
@@ -503,8 +510,7 @@ function captureViewportCanvas(
frameRect: exportFrameRect,
heightScale,
labels,
viewportHeight,
viewportWidth,
projectionRect: renderViewportRect ?? { left: 0, top: 0, width: viewportWidth, height: viewportHeight },
widthScale,
});
}
@@ -567,25 +573,60 @@ function CanvasCaptureBridge({
metaTarget?: [number, number, number],
lens?: ResolvedLens | null
) => {
withViewportCaptureHelpersHidden(scene, () => {
// 机位视角带镜头参数 → bokeh 后处理渲染(浅景深);否则普通渲染(导演视角 / 环绕预览)。
if (lens) renderSceneWithBokeh(gl, scene, workingCamera, lens);
else gl.render(scene, workingCamera);
});
return {
label,
dataUrl: captureViewportCanvas(gl.domElement, viewportAspectRatio, bottomPadding, safeAreaInsets, {
camera: workingCamera,
labels: getViewportCaptureLabels(scene),
}),
meta: buildScreenshotMeta({
mode: viewMode,
cameraId: cameraId ?? (viewMode === "camera" ? activeCamera?.id ?? null : null),
fov: workingCamera.fov,
position: [workingCamera.position.x, workingCamera.position.y, workingCamera.position.z],
target: metaTarget ?? [target.x, target.y, target.z],
}),
};
const canvasCssWidth = gl.domElement.clientWidth || gl.domElement.width;
const canvasCssHeight = gl.domElement.clientHeight || gl.domElement.height;
// 机位视角(带镜头参数)与视口「机位视角」用同一个渲染矩形:按传感器画幅在画布上等比适配
// (画布更宽 → 高度铺满、左右留白居中)。相机 aspect 必须与矩形一致,否则投影被拉伸;
// 渲染进矩形后按同一矩形裁切,截图才和视口所见 / 镜头监视器逐像素一致。
const renderRect = lens
? resolveCameraViewRenderRect(
canvasCssWidth,
canvasCssHeight,
lens.sensor.widthMm / lens.sensor.heightMm
)
: null;
// 没有镜头参数(导演视角 / 四方位环绕预览)时按整块画布渲染再裁画幅框:
// 相机 aspect 对齐画布宽高比,裁出来的画幅框才不变形。
const previousAspect = workingCamera.aspect;
const nextAspect = renderRect ? renderRect.aspect : canvasCssWidth / canvasCssHeight;
if (nextAspect > 0 && Number.isFinite(nextAspect) && Math.abs(previousAspect - nextAspect) > 1e-4) {
workingCamera.aspect = nextAspect;
workingCamera.updateProjectionMatrix();
}
try {
withViewportCaptureHelpersHidden(scene, () => {
// 机位视角带镜头参数 → bokeh 后处理渲染(浅景深);否则普通渲染(导演视角 / 环绕预览)。
if (lens) renderSceneWithBokeh(gl, scene, workingCamera, lens, renderRect ?? undefined);
else gl.render(scene, workingCamera);
});
return {
label,
dataUrl: captureViewportCanvas(
gl.domElement,
viewportAspectRatio,
bottomPadding,
safeAreaInsets,
{
camera: workingCamera,
labels: getViewportCaptureLabels(scene),
},
renderRect ?? undefined
),
meta: buildScreenshotMeta({
mode: viewMode,
cameraId: cameraId ?? (viewMode === "camera" ? activeCamera?.id ?? null : null),
fov: workingCamera.fov,
position: [workingCamera.position.x, workingCamera.position.y, workingCamera.position.z],
target: metaTarget ?? [target.x, target.y, target.z],
}),
};
} finally {
if (Math.abs(workingCamera.aspect - previousAspect) > 1e-4) {
workingCamera.aspect = previousAspect;
workingCamera.updateProjectionMatrix();
}
}
};
if (preset === "current") {
@@ -1135,6 +1176,8 @@ export function DirectorCanvas() {
</div>
<ViewportAspectOverlay
bottomPadding={aspectOverlayBottomPadding}
// 机位视角下框外就是相机画面(镜头监视器那块 canvas),不能磨砂,否则会把画面糊掉
blurOutsideFrame={viewMode !== "camera"}
onToggleRuleOfThirds={setViewportRuleOfThirdsEnabled}
ratio={viewportAspectRatio}
safeAreaInsets={viewportSafeAreaInsets}

View File

@@ -1,6 +1,6 @@
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import { PerspectiveCamera as DreiPerspectiveCamera } from "@react-three/drei";
import { useLayoutEffect, useMemo, useRef, useState } from "react";
import { memo, 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";
@@ -9,6 +9,7 @@ import { SceneRoot } from "./SceneRoot";
import { ViewportBackground } from "./ViewportBackground";
import { PlaybackTransformSync } from "./PlaybackTransformSync";
import { renderSceneWithBokeh } from "./viewportBokeh";
import { resolveCameraViewRenderRect } from "./viewportAspectFrame";
import {
formatLensInfo,
getCameraRenderQuaternion,
@@ -21,16 +22,22 @@ import type { CameraKeyframeValue, CameraTimelineTrack } from "../schema/timelin
import { evaluateCameraViewAtTime, getActiveCameraIdAtTime } from "../schema/timeline";
/**
* 镜头监视器:独立于主视口(即便主视口是「导演视角」也照常显示当前机位看到的内容)。
* 复用 SceneRoot 渲染同一份场景(不带编辑器辅助),并按传感器宽高比裁切画幅、
* 用 ViewportBackground 渲染与主视口 / 导出一致的全景背景,通过共享的 renderSceneWithBokeh
* 反映光圈带来的景深虚化。UI 上的小字显示当前镜头参数。
* 镜头监视器:**机位画面的唯一渲染源**。
*
* - 导演视角下:它是一块独立的小窗(`right` 偏移悬浮在画布右上角),随时显示当前机位看到的内容;
* - 机位视角下:它直接顶到主视口那块画幅的位置上(`.lens-monitor.is-viewport-camera`,尺寸取
* `resolveCameraViewRenderRect` 算出的矩形),**机位视角显示的就是这块 canvas 的像素** ——
* 同一份渲染、同一个画幅,所以「机位视角 == 监视器 == 导出」不可能再出现取景 / 虚化不一致,
* 主视口那边也不必重复跑一遍完整的景深 composer + 深度 pass(省掉约一半开销)。
*
* 复用 SceneRoot 渲染同一份场景(不带编辑器辅助),按传感器宽高比裁切画幅、
* 用 ViewportBackground 渲染与导出一致的全景背景,通过共享的 renderSceneWithBokeh 反映景深虚化。
*
* 「当前机位」= 时间轴此刻正在显示的机位(多机位自动切换 / 过渡时会变),与视频导出用同一个
* getActiveCameraIdAtTime 解析;位姿、镜头(焦段/光圈/对焦/景深强度)、画幅全部取自这台机位,
* 保证监视器所见与导出的机位视角严格一致。
*
* 刻意做成独立的第二个 Canvas:景深后处理只作用在监视器上,不影响主视口 / 截图的渲染管线。
* 刻意做成独立的第二个 Canvas:景深后处理只走这一条管线,主视口 / 截图不必各写一套。
*/
function LensMonitorCamera({
@@ -38,14 +45,12 @@ function LensMonitorCamera({
sceneSettings,
sceneRef,
camRef,
playhead,
cameraTracks,
}: {
camera: DirectorCameraShot;
sceneSettings: SceneSettings;
sceneRef: React.MutableRefObject<Scene | null>;
camRef: React.MutableRefObject<ThreePerspectiveCamera | null>;
playhead: number;
cameraTracks: CameraTimelineTrack[];
}) {
const { gl, scene } = useThree();
@@ -56,6 +61,11 @@ function LensMonitorCamera({
const transitionClips = useDirectorStore((state) => state.project.timeline.transitionClips);
const globalTransition = useDirectorStore((state) => state.project.timeline.cameraTransition);
const isExporting = usePlaybackStore((state) => state.isExporting);
// playhead 自己订阅(而不是从外层当 prop 传进来):外壳组件不必因为这个每秒变几十次的值
// 去 reconcile 整棵场景树(见 LensMonitorCanvas 的 memo)。
const playhead = usePlaybackStore((state) => state.playhead);
// 拖动播放头期间走草稿档:跳过景深整套后处理,把帧预算留给「跟手」
const isScrubbing = usePlaybackStore((state) => state.isScrubbing);
// 关键帧感知:拖播放头时,监视器上的对焦距离 / 光圈 / 焦段 / 景深强度都按关键帧插值,
// 让「移焦 / 渐入渐出虚化 / 变焦」在监视器上所见即所得(与导出管线一致的 resolveLensAtTime)。
const lens = useMemo(
@@ -118,7 +128,8 @@ function LensMonitorCamera({
// 与主视口 / 视频导出共用同一份景深渲染(renderSceneWithBokeh):监视器不再自建一套
// EffectComposer —— 两套实现很容易漂移,导出与监视器的取景 / 虚化就会对不上。
// 合成器按 gl 缓存、画布尺寸变化会自动重建;不支持后处理时回退普通渲染,监视器永远可用。
renderSceneWithBokeh(gl, scene, renderCamera, lens);
// 拖动播放头时传 draft:跳过整套景深后处理(松手立刻恢复),拖动才跟手。
renderSceneWithBokeh(gl, scene, renderCamera, lens, undefined, { draft: isScrubbing });
}, 1);
return (
@@ -132,6 +143,71 @@ function LensMonitorCamera({
);
}
/**
* 监视器那块 3D 画布:整棵场景树(SceneRoot 会把当前工程所有物体铺进来)。
*
* 单独抽成 memo 组件的原因:拖动播放头时 playhead 每秒变几十次,如果这棵树长在外壳组件里,
* 每动一下都要把整棵场景树 reconcile 一遍 —— 那是拖动卡顿的一大来源。这里 props 只在
* 「换机位 / 改场景 / 换全景图」这类真实编辑时变,播放头由 LensMonitorCamera 自己订阅。
*/
const LensMonitorCanvas = memo(function LensMonitorCanvas({
camera,
sceneSettings,
panoramaAsset,
cameraTracks,
sceneRef,
camRef,
initialCamera,
}: {
camera: DirectorCameraShot;
sceneSettings: SceneSettings;
panoramaAsset: React.ComponentProps<typeof ViewportBackground>["panoramaAsset"];
cameraTracks: CameraTimelineTrack[];
sceneRef: React.MutableRefObject<Scene | null>;
camRef: React.MutableRefObject<ThreePerspectiveCamera | null>;
/** 画布的初始相机参数:只取首次渲染那一刻的值(位姿 / fov 由 LensMonitorCamera 每帧写) */
initialCamera: { fov: number; position: [number, number, number] };
}) {
return (
<Canvas
className="lens-monitor-canvas"
// 这块画布在机位视角下就是主视口那块画幅(尺寸跟着画幅走),所以 dpr 上限收一档:
// 大画布上跑到 2x 会让「景深 composer + 深度 pass」的像素量翻倍,是之前发卡的主因;
// 1.5x 在高分屏上肉眼几乎无差别,开销却少 ~45%。导出不受影响(导出自己设 1x + 导出分辨率)。
dpr={[1, 1.5]}
// preserveDrawingBuffer:机位视角导出就是把这块画布的内容逐帧读进 VideoFrame,
// 不保留绘画缓冲会读到空帧。
gl={{ antialias: true, preserveDrawingBuffer: true }}
camera={initialCamera}
>
<color attach="background" args={[sceneSettings.backgroundColor]} />
{/* 与主视口 / 导出共用同一份背景实现:有全景图时渲染全景(equirect 走 scene.background、
backdrop 走穹顶网格),否则用背景色。少了它,监视器会和导出画面差一个背景。 */}
<ViewportBackground
backgroundColor={sceneSettings.backgroundColor}
panoramaAsset={panoramaAsset}
panoramaRadius={sceneSettings.panoramaRadius}
panoramaYaw={sceneSettings.panoramaYaw}
/>
<ambientLight intensity={1.15} />
<directionalLight intensity={1.2} position={[8, 10, 6]} />
{/* 与主视口 / 视频导出共用同一份时间轴变换:把物体 / 角色关键帧的插值写进监视器场景。
SceneRoot 自身只取 item.transform 和人物轨迹,不应用时间轴轨道 —— 少了这一层,
监视器里的物体就不会随关键帧运动,物体到相机的深度和导出不同,
清晰平面落在别处,看起来就是「对焦距离和导出对不上」。 */}
<PlaybackTransformSync />
<LensMonitorCamera
camera={camera}
sceneSettings={sceneSettings}
sceneRef={sceneRef}
camRef={camRef}
cameraTracks={cameraTracks}
/>
<SceneRoot editorHelpers={false} />
</Canvas>
);
});
/** 鼠标每像素对应的角度增量(度) */
const LOOK_DRAG_SENSITIVITY = 0.3;
/** 俯仰角夹到 ±89°,避免越过天顶后镜头翻转 */
@@ -174,6 +250,60 @@ export function LensMonitor({ rightOffset = 12 }: { rightOffset?: number }) {
// 小字镜头信息也按播放头解析(与画面渲染同一份 resolveLensAtTime),点击/拖到关键帧时数字同步刷新
const lens = camera ? resolveLensAtTime(camera, playhead, cameraTracks) : null;
const sensorAspect = lens ? lens.sensor.widthMm / lens.sensor.heightMm : 16 / 9;
const viewMode = useDirectorStore((state) => state.viewMode);
// 机位视角下,这块监视器直接顶到主视口「机位画面」的位置上,成为那块画幅本身:
// 机位视角显示的**就是这块 canvas 的像素**(同一份渲染、同一个画幅),
// 于是「机位视角 == 镜头监视器 == 导出」天然一致,不会再出现取景 / 虚化两边对不上;
// 主视口那边同时不再重复渲染一遍完整景深管线(少一半开销,之前又卡又糊的主因)。
const rootRef = useRef<HTMLDivElement | null>(null);
const [viewportFrame, setViewportFrame] = useState<{ left: number; top: number; width: number; height: number } | null>(
null
);
useLayoutEffect(() => {
// 画幅是相对「画布」等比适配出来的,而这块监视器的父元素正是 .canvas-frame(position: relative)
const frame = rootRef.current?.parentElement ?? null;
if (!frame) return undefined;
let animationFrameId = 0;
const measure = () => {
const rect = resolveCameraViewRenderRect(frame.clientWidth, frame.clientHeight, sensorAspect);
setViewportFrame((current) =>
current && rect && current.left === rect.left && current.top === rect.top && current.width === rect.width && current.height === rect.height
? current
: rect
);
};
const scheduleMeasure = () => {
cancelAnimationFrame(animationFrameId);
animationFrameId = requestAnimationFrame(measure);
};
measure();
window.addEventListener("resize", scheduleMeasure);
if (typeof ResizeObserver === "undefined") {
return () => {
cancelAnimationFrame(animationFrameId);
window.removeEventListener("resize", scheduleMeasure);
};
}
const resizeObserver = new ResizeObserver(scheduleMeasure);
resizeObserver.observe(frame);
return () => {
cancelAnimationFrame(animationFrameId);
window.removeEventListener("resize", scheduleMeasure);
resizeObserver.disconnect();
};
}, [sensorAspect]);
const viewportCameraFrame = viewMode === "camera" && camera ? viewportFrame : null;
// 画布初始相机参数只在首次渲染那一刻取一次:位姿 / fov 每帧都由 LensMonitorCamera 写,
// 保持这个对象引用稳定,<Canvas> 才不会因为播放头变化被重新应用 camera props。
const initialCanvasCameraRef = useRef<{ fov: number; position: [number, number, number] } | null>(null);
initialCanvasCameraRef.current ??= { fov: lens?.fov ?? 50, position: [0, 1, 5] };
const initialCanvasCamera = initialCanvasCameraRef.current;
const [dragging, setDragging] = useState(false);
const dragRef = useRef<{
startX: number;
@@ -253,11 +383,29 @@ export function LensMonitor({ rightOffset = 12 }: { rightOffset?: number }) {
};
return (
<div className="lens-monitor" aria-label="镜头监视器" style={{ right: `${rightOffset}px` }}>
<div
ref={rootRef}
className={`lens-monitor${viewportCameraFrame ? " is-viewport-camera" : ""}`}
aria-label="镜头监视器"
style={
viewportCameraFrame
? {
left: `${viewportCameraFrame.left}px`,
top: `${viewportCameraFrame.top}px`,
width: `${viewportCameraFrame.width}px`,
}
: { right: `${rightOffset}px` }
}
>
<div
className="lens-monitor-stage"
ref={stageRef}
style={{ aspectRatio: String(sensorAspect), cursor: dragging ? "grabbing" : "grab" }}
style={{
aspectRatio: String(sensorAspect),
// 机位视角下高度也要精确落在画幅上(宽高由同一个矩形算出来,避免 aspect 取整误差留下细缝)
height: viewportCameraFrame ? `${viewportCameraFrame.height}px` : undefined,
cursor: dragging ? "grabbing" : "grab",
}}
title="拖拽改变机位注视角度(Shift+拖拽旋转横滚角);单击画面中某点使其对焦清晰"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
@@ -265,42 +413,15 @@ export function LensMonitor({ rightOffset = 12 }: { rightOffset?: number }) {
onPointerCancel={endDrag}
>
{camera && lens ? (
<Canvas
className="lens-monitor-canvas"
// 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]} />
{/* 与主视口 / 导出共用同一份背景实现:有全景图时渲染全景(equirect 走 scene.background、
backdrop 走穹顶网格),否则用背景色。少了它,监视器会和导出画面差一个背景。 */}
<ViewportBackground
backgroundColor={sceneSettings.backgroundColor}
panoramaAsset={panoramaAsset}
panoramaRadius={sceneSettings.panoramaRadius}
panoramaYaw={sceneSettings.panoramaYaw}
/>
<ambientLight intensity={1.15} />
<directionalLight intensity={1.2} position={[8, 10, 6]} />
{/* 与主视口 / 视频导出共用同一份时间轴变换:把物体 / 角色关键帧的插值写进监视器场景。
SceneRoot 自身只取 item.transform 和人物轨迹,不应用时间轴轨道 —— 少了这一层,
监视器里的物体就不会随关键帧运动,物体到相机的深度和导出不同,
清晰平面落在别处,看起来就是「对焦距离和导出对不上」。 */}
<PlaybackTransformSync />
<LensMonitorCamera
camera={camera}
sceneSettings={sceneSettings}
sceneRef={monitorSceneRef}
camRef={monitorCamRef}
playhead={playhead}
cameraTracks={cameraTracks}
/>
<SceneRoot editorHelpers={false} />
</Canvas>
<LensMonitorCanvas
camera={camera}
cameraTracks={cameraTracks}
camRef={monitorCamRef}
initialCamera={initialCanvasCamera}
panoramaAsset={panoramaAsset}
sceneRef={monitorSceneRef}
sceneSettings={sceneSettings}
/>
) : (
<div className="lens-monitor-empty">无激活机位</div>
)}

View File

@@ -13,12 +13,19 @@ export function ViewportAspectOverlay({
showRuleOfThirds = false,
onToggleRuleOfThirds,
safeAreaInsets,
blurOutsideFrame = true,
}: {
ratio: ViewportAspectRatio;
bottomPadding?: number;
showRuleOfThirds?: boolean;
onToggleRuleOfThirds?: (enabled: boolean) => void;
safeAreaInsets?: ViewportSafeAreaInsets;
/**
* 画幅框外是否做磨砂(backdrop blur)。导演视角下是一层构图遮罩;**机位视角下必须关掉**:
* 那里框外仍然是相机画面(机位视角按传感器画幅铺满画布),磨砂会把相机画面整片糊掉,
* 和镜头监视器 / 导出对不上。关掉后只留一层压暗 + 画幅框边线 + 九宫格,构图参考照样在。
*/
blurOutsideFrame?: boolean;
}) {
const overlayRef = useRef<HTMLDivElement | null>(null);
const [overlaySize, setOverlaySize] = useState({ width: 0, height: 0 });
@@ -116,7 +123,12 @@ export function ViewportAspectOverlay({
return (
<div className="viewport-aspect-overlay" ref={overlayRef}>
{maskStyle ? (
<div className="viewport-aspect-mask" aria-label="视口画幅遮罩" aria-hidden="true" style={maskStyle} />
<div
className={`viewport-aspect-mask${blurOutsideFrame ? "" : " is-dim-only"}`}
aria-label="视口画幅遮罩"
aria-hidden="true"
style={maskStyle}
/>
) : null}
<div className="viewport-aspect-frame-shell" aria-label="视口画幅框" data-aspect-ratio={ratio} style={shellStyle}>
<button

View File

@@ -0,0 +1,102 @@
import type { BokehPass } from "three/examples/jsm/postprocessing/BokehPass.js";
import { BOKEH_TAP_RADIUS_RATIO } from "./bokehSettings";
/**
* 自适应采样的景深片元着色器:替换 three 原版 BokehShader 的「合成」部分。
*
* 与 three 原版的差异**只有采样点的摆法**,模糊半径的语义完全一致(都是 UV = 占画面宽度的百分比):
* - 原版:41 个点按 4 个同心圆环摆(外圈 16 个、其余各 8 个)。半径一大,环与环之间就空出来,
* 在 1500+ px 的机位视角画布上会看成「一圈一圈的重影 / 空心光斑」;而 320 px 的镜头监视器
* 半径小、采样密集,糊得很柔 —— 两边看起来就不是同一种虚化。
* - 这里:金角螺旋(每步转黄金角 2.39996323 rad)把 N 个点均匀铺在圆盘里,间距处处均匀,
* N 由 CPU 按「模糊半径有多少设备像素」选档后以 #define 编译进来(见 bokehSettings)。
* 循环边界因此是常量,完全满足 GLSL ES 1.0 对循环的限制。
*
* 深度解码(DEPTH_PACKING / PERSPECTIVE_CAMERA、unpackRGBAToDepth、perspectiveDepthToViewZ)
* 与原版逐行一致,仍然吃 BokehPass 自己渲染的那张深度图,所以对焦距离 / 光圈的解释不会变。
*/
export const BOKEH_TAPS_DEFINE = "BOKEH_TAPS";
export const BOKEH_RADIUS_RATIO_DEFINE = "BOKEH_RADIUS_RATIO";
export const ADAPTIVE_BOKEH_FRAGMENT_SHADER = /* glsl */ `
#include <common>
varying vec2 vUv;
uniform sampler2D tColor;
uniform sampler2D tDepth;
uniform float maxblur;
uniform float aperture;
uniform float nearClip;
uniform float farClip;
uniform float focus;
uniform float aspect;
#include <packing>
float getDepth( const in vec2 screenPosition ) {
#if DEPTH_PACKING == 1
return unpackRGBAToDepth( texture2D( tDepth, screenPosition ) );
#else
return texture2D( tDepth, screenPosition ).x;
#endif
}
float getViewZ( const in float depth ) {
#if PERSPECTIVE_CAMERA == 1
return perspectiveDepthToViewZ( depth, nearClip, farClip );
#else
return orthographicDepthToViewZ( depth, nearClip, farClip );
#endif
}
void main() {
vec2 aspectcorrect = vec2( 1.0, aspect );
float viewZ = getViewZ( getDepth( vUv ) );
// viewZ <= 0,所以 focus + viewZ 就是「对焦距离 − 该像素的距离」:正=后景、负=前景
float factor = focus + viewZ;
// 模糊半径用 UV 表达(与 three 原版一致):机位视角 / 镜头监视器 / 导出成片的相对虚化量因此严格一致
float radius = clamp( abs( factor ) * aperture, 0.0, maxblur ) * BOKEH_RADIUS_RATIO;
vec3 col = vec3( 0.0 );
for ( int i = 0; i < BOKEH_TAPS; i ++ ) {
// 半径按 sqrt 铺满圆盘(面积均匀),角度每步加黄金角 → 采样点散得最开、不成环
float ringRadius = radius * sqrt( ( float( i ) + 0.5 ) / float( BOKEH_TAPS ) );
float angle = float( i ) * 2.39996323;
vec2 offset = vec2( cos( angle ), sin( angle ) ) * ringRadius * aspectcorrect;
col += texture2D( tColor, vUv + offset ).rgb;
}
gl_FragColor = vec4( col / float( BOKEH_TAPS ), 1.0 );
}
`;
/**
* 把 BokehPass 的合成着色器换成自适应版本,并按 `taps` 写进 #define。
* 幂等:档位没变时不做任何事(改 defines 会触发重新编译,没必要每帧都来一次)。
*/
export function configureAdaptiveBokeh(pass: BokehPass, taps: number) {
const material = pass.materialBokeh;
const nextTaps = String(taps);
const nextRadiusRatio = String(BOKEH_TAP_RADIUS_RATIO);
if (
material.fragmentShader === ADAPTIVE_BOKEH_FRAGMENT_SHADER &&
material.defines?.[BOKEH_TAPS_DEFINE] === nextTaps &&
material.defines?.[BOKEH_RADIUS_RATIO_DEFINE] === nextRadiusRatio
) {
return;
}
material.fragmentShader = ADAPTIVE_BOKEH_FRAGMENT_SHADER;
// 保留 BokehPass 自带的 DEPTH_PACKING / PERSPECTIVE_CAMERA,只补两个自己的
material.defines = {
...material.defines,
[BOKEH_TAPS_DEFINE]: nextTaps,
[BOKEH_RADIUS_RATIO_DEFINE]: nextRadiusRatio,
};
material.needsUpdate = true;
}

View File

@@ -0,0 +1,116 @@
import { describe, expect, it } from "vitest";
import { getSensorPreset, type ResolvedLens } from "../schema/cameraGeometry";
import {
BOKEH_MAX_TAPS,
BOKEH_MIN_TAPS,
BOKEH_TAP_RADIUS_RATIO,
computeBokehUniforms,
getBokehBlurRadiusPx,
resolveBokehTapCount,
} from "./bokehSettings";
function makeLens(overrides: Partial<ResolvedLens> = {}): ResolvedLens {
return {
sensorPreset: "fullFrame",
sensor: getSensorPreset("fullFrame"),
focalLengthMm: 35,
apertureFNumber: 2.8,
focusDistance: 5,
bokehMaxBlur: 0.012,
fov: 54,
...overrides,
};
}
// 三处景深通道的真实画布:机位视角(按传感器画幅铺满画布高度 → 很宽、dpr 2)、
// 镜头监视器(stage 固定 320px)、视频导出(短边 720)
const CAMERA_VIEW = { renderWidth: 1500, renderHeight: 1000, pixelRatio: 2 };
const LENS_MONITOR = { renderWidth: 318, renderHeight: 212, pixelRatio: 1 };
const VIDEO_EXPORT = { renderWidth: 1280, renderHeight: 720, pixelRatio: 1 };
describe("computeBokehUniforms", () => {
it("feeds the camera view, the lens monitor and the export the same blur parameters", () => {
const lens = makeLens();
const [cameraView, lensMonitor, videoExport] = [CAMERA_VIEW, LENS_MONITOR, VIDEO_EXPORT].map((size) =>
computeBokehUniforms({ lens, ...size })
);
for (const settings of [cameraView, lensMonitor, videoExport]) {
// 对焦距离 / 光圈斜坡 / 最大模糊量只跟镜头有关:三处一致,虚化强弱才一致
expect(settings.focus).toBe(lens.focusDistance);
expect(settings.maxblur).toBe(lens.bokehMaxBlur);
expect(settings.aperture).toBeCloseTo(cameraView.aperture, 12);
}
});
it("keeps the blur radius in UV so the relative depth of field matches the final video", () => {
const lens = makeLens({ bokehMaxBlur: 0.03 });
const cameraView = computeBokehUniforms({ lens, ...CAMERA_VIEW });
const videoExport = computeBokehUniforms({ lens, ...VIDEO_EXPORT });
// maxblur 是 UV(占画面宽度的百分比),不随画布分辨率变 → 相对虚化量恒定
expect(cameraView.maxblur).toBe(0.03);
expect(videoExport.maxblur).toBe(0.03);
});
it("derives the aspect from each render canvas so the blur disc stays circular", () => {
const lens = makeLens();
// 机位视角 / 监视器都按传感器画幅渲染,所以两者 aspect 都等于传感器比例(3:2)
expect(computeBokehUniforms({ lens, ...CAMERA_VIEW }).aspect).toBeCloseTo(1.5, 10);
expect(computeBokehUniforms({ lens, ...LENS_MONITOR }).aspect).toBeCloseTo(1.5, 10);
// 导出按导出分辨率出片,aspect 取导出画幅
expect(computeBokehUniforms({ lens, ...VIDEO_EXPORT }).aspect).toBeCloseTo(1280 / 720, 10);
});
it("falls back to the sensor aspect when the canvas size is not measurable yet", () => {
const lens = makeLens();
expect(computeBokehUniforms({ lens, renderWidth: 0, renderHeight: 0 }).aspect).toBeCloseTo(1.5, 10);
expect(computeBokehUniforms({ lens, renderWidth: 0, renderHeight: 0 }).taps).toBe(BOKEH_MIN_TAPS);
});
});
describe("resolveBokehTapCount", () => {
it("computes the max blur radius in device pixels", () => {
expect(getBokehBlurRadiusPx(0.012, 1000)).toBeCloseTo(BOKEH_TAP_RADIUS_RATIO * 12, 10);
expect(getBokehBlurRadiusPx(0, 1000)).toBe(0);
});
it("stays at the three.js default tap count for the small canvases (monitor / export)", () => {
// 监视器 / 导出画布小、半径只有几像素,41 个点就已经很密,没必要加采样(开销与以前完全一样)
expect(resolveBokehTapCount(0.012, 318)).toBe(BOKEH_MIN_TAPS);
expect(resolveBokehTapCount(0.05, 318)).toBe(BOKEH_MIN_TAPS);
expect(resolveBokehTapCount(0.012, 1280)).toBe(BOKEH_MIN_TAPS);
});
it("starts adding taps on the wide camera-view canvas once the radius grows", () => {
// 机位视角(1500 CSS px × dpr2 = 3000 设备像素):默认景深强度下半径约 14px,
// 维持 41 个点;景深强度拉大后必须加密,否则会看出「一圈一圈的重影」
expect(resolveBokehTapCount(0.012, 3000)).toBe(BOKEH_MIN_TAPS);
expect(resolveBokehTapCount(0.05, 3000)).toBeGreaterThan(BOKEH_MIN_TAPS);
});
it("never exceeds the tap budget and never returns a smaller count for a bigger radius", () => {
const widths = [318, 1280, 3000, 4000];
const blurs = [0, 0.006, 0.012, 0.024, 0.05];
for (const width of widths) {
let previous = 0;
for (const blur of blurs) {
const taps = resolveBokehTapCount(blur, width);
expect(taps).toBeGreaterThanOrEqual(BOKEH_MIN_TAPS);
expect(taps).toBeLessThanOrEqual(BOKEH_MAX_TAPS);
expect(taps).toBeGreaterThanOrEqual(previous);
previous = taps;
}
}
});
it("keeps at least the minimum taps even when the depth of field is switched off", () => {
// maxblur = 0 时 shader 的半径是 0,所有采样点都落在中心像素上(等于不虚化),
// 但循环仍然要有采样点,不能退化成 0
expect(resolveBokehTapCount(0, 3000)).toBe(BOKEH_MIN_TAPS);
});
});

View File

@@ -0,0 +1,95 @@
import { apertureToBokeh, type ResolvedLens } from "../schema/cameraGeometry";
/**
* 景深参数(BokehPass 的 uniform + 采样点数)计算 —— 机位视角 / 镜头监视器 / 视频导出三处共用。
*
* 为什么单独抽成一个纯函数:
* - three 的 BokehShader 用 `dofblur = clamp((focus − 距离) × aperture, ±maxblur)` 算模糊半径,
* 单位是 **UV(占画面宽度的百分比)**。所以只要三处喂同一份 lens,「相对虚化量」天然一致 ——
* 这一条必须保证,否则「取景器里看到的虚化 ≠ 成片里的虚化」。
* - 但原版只有 **41 个固定采样点**,还按四个同心圆环摆。半径一大(机位视角这种 1500+ px 的画布上,
* 半径是监视器 320 px 画布的好几倍)采样点间距就被拉开到 5~6 px,看出「一圈一圈的重影 / 空心光斑」,
* 而小画布上因为采样密集糊得很柔 —— 两边看起来就不是同一种虚化。
* 所以采样点数按「模糊半径有多少设备像素」自适应(见 resolveBokehTapCount),
* 具体点数以 #define 编译进 shader(见 adaptiveBokehShader)。
*/
/** shader 里最外圈采样点落在 0.4 × dofblur 处(与 three 原版图案的最大半径一致) */
export const BOKEH_TAP_RADIUS_RATIO = 0.4;
/**
* 采样点目标间距(设备像素):间距超过它就会看出重影 / 空心光斑。
* 调小 = 更柔顺但更贵;调大 = 更省但更容易看出采样点。
*/
export const BOKEH_TAP_TARGET_SPACING_PX = 5;
/**
* 采样点数档位(写进 shader 的编译期常量)。
* 41 与 three 原版一致 —— 小画布(监视器 / 导出)稳定停在 41,开销和以前完全一样;
* 只有机位视角这种大画布 + 大模糊半径才会逐级加密,最多 96(约 2.3 倍采样开销)。
*/
export const BOKEH_TAP_STEPS = [41, 64, 96] as const;
export const BOKEH_MIN_TAPS: number = BOKEH_TAP_STEPS[0];
export const BOKEH_MAX_TAPS: number = BOKEH_TAP_STEPS[BOKEH_TAP_STEPS.length - 1];
/** 最大模糊半径(设备像素):dofblur 被 clamp 到 maxblur,最外圈半径就是 0.4 × maxblur × 渲染宽度 */
export function getBokehBlurRadiusPx(maxblur: number, renderWidthPx: number) {
return BOKEH_TAP_RADIUS_RATIO * Math.abs(maxblur) * Math.max(renderWidthPx, 0);
}
/**
* 金角螺旋在半径 R 的圆盘里铺 N 个点,平均间距 ≈ R·√(π/N),所以 N ≈ π·(R ÷ 目标间距)²。
* 取「不小于需求的最小档位」,保证:
* - 小半径(监视器、导出)停在第 1 档 41,和以前一样便宜;
* - 大画布上半径大 → 加密采样 → 不再出现重影;
* - 上限 BOKEH_MAX_TAPS,不会因为极端设置把帧率拖垮。
*/
export function resolveBokehTapCount(maxblur: number, renderWidthPx: number): number {
const radiusPx = getBokehBlurRadiusPx(maxblur, renderWidthPx);
const required = Math.PI * (radiusPx / BOKEH_TAP_TARGET_SPACING_PX) ** 2;
return BOKEH_TAP_STEPS.find((step) => step >= required) ?? BOKEH_MAX_TAPS;
}
export interface BokehUniforms {
/** 对焦距离(世界单位 / 米):BokehShader 的 focus */
focus: number;
/** 光圈斜坡:BokehShader 的 aperture(已按对焦距离换算) */
aperture: number;
/** 最大模糊量(UV):BokehShader 的 maxblur */
maxblur: number;
/** 宽高比:shader 里竖直方向的扩散量按它缩放,三处必须相同,否则虚化形状都不一样 */
aspect: number;
/** 采样点数(档位值,会写进 shader 的 #define) */
taps: number;
}
/**
* 由「镜头 + 这块画布的实际渲染尺寸」算出景深参数。
*
* @param renderWidth / renderHeight 合成器尺寸(CSS 像素)——aspect 用它们的比值;
* @param pixelRatio 只用来把模糊半径换算到设备像素,进而决定采样点数(图省事可直接用 1)。
*/
export function computeBokehUniforms({
lens,
renderWidth,
renderHeight,
pixelRatio = 1,
}: {
lens: ResolvedLens;
renderWidth: number;
renderHeight: number;
pixelRatio?: number;
}): BokehUniforms {
const width = Math.max(renderWidth, 0);
const height = Math.max(renderHeight, 0);
return {
focus: lens.focusDistance,
// aperture 必须带上对焦距离:景深清晰带要随对焦距离平方展开,
// 否则对焦到远景时清晰带仍只有约 ±1.1m,连远景都糊(详见 apertureToBokeh 注释)。
aperture: apertureToBokeh(lens.apertureFNumber, lens.focusDistance),
maxblur: lens.bokehMaxBlur,
// 合成器是缓存复用的:尺寸拿不到有效值时回落到传感器画幅,避免 aspect 变成 0 / NaN。
aspect: width > 0 && height > 0 ? width / height : lens.sensor.widthMm / lens.sensor.heightMm,
taps: resolveBokehTapCount(lens.bokehMaxBlur, width * Math.max(pixelRatio, 0)),
};
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { fitFrameWithinViewport } from "./viewportAspectFrame";
import { fitFrameWithinViewport, resolveCameraViewRenderRect } from "./viewportAspectFrame";
describe("fitFrameWithinViewport", () => {
it("keeps framed aspect layouts inside the visible safe area between overlay side panels", () => {
@@ -16,3 +16,38 @@ describe("fitFrameWithinViewport", () => {
expect(frame.top + frame.height).toBeLessThanOrEqual(574);
});
});
describe("resolveCameraViewRenderRect", () => {
it("fills the canvas height and pads the width, centered, when the canvas is wider than the sensor", () => {
const rect = resolveCameraViewRenderRect(1920, 1000, 16 / 9)!;
expect(rect.height).toBeCloseTo(1000, 10);
expect(rect.width).toBeCloseTo((1000 * 16) / 9, 10);
expect(rect.top).toBeCloseTo(0, 10);
// 左右留白相等 → 画面在中间
expect(rect.left).toBeCloseTo((1920 - rect.width) / 2, 10);
expect(1920 - (rect.left + rect.width)).toBeCloseTo(rect.left, 10);
expect(rect.aspect).toBeCloseTo(16 / 9, 10);
});
it("fills the canvas width and pads the height, centered, when the canvas is narrower than the sensor", () => {
const rect = resolveCameraViewRenderRect(800, 1000, 16 / 9)!;
expect(rect.width).toBeCloseTo(800, 10);
expect(rect.height).toBeCloseTo(800 / (16 / 9), 10);
expect(rect.left).toBeCloseTo(0, 10);
expect(rect.top).toBeCloseTo((1000 - rect.height) / 2, 10);
});
it("keeps the camera frame identical to the lens sensor aspect", () => {
const rect = resolveCameraViewRenderRect(1920, 1000, 3 / 2)!;
expect(rect.aspect).toBeCloseTo(3 / 2, 10);
expect(rect.width / rect.height).toBeCloseTo(3 / 2, 10);
});
it("returns null before the canvas has a measurable size", () => {
expect(resolveCameraViewRenderRect(0, 0, 16 / 9)).toBeNull();
expect(resolveCameraViewRenderRect(1600, 900, 0)).toBeNull();
});
});

View File

@@ -67,3 +67,43 @@ export function getViewportAspectFrameRect(
return fitFrameWithinViewport(width, height, ratioValue, bottomPadding, safeAreaInsets);
}
/** 「机位视角」渲染矩形(CSS 像素,相对画布左上角;与 ViewportFrameRect 同口径) */
export interface CameraViewRenderRect extends ViewportFrameRect {
/** 渲染相机的 aspect 必须等于它,画面才不会被拉伸 */
aspect: number;
}
/**
* 解析「机位视角」的渲染矩形——视口里的机位画面到底铺在哪一块。
*
* 取景基准统一按**传感器画幅**(和镜头监视器 / 视频导出同一颗「底片」),在画布上等比适配(contain):
* - 画布比传感器画幅更宽 → 画面高度铺满画布、宽度按比例留白(左右留边);
* - 画布比传感器画幅更窄 → 宽度铺满、上下留边;
* 两种情况都居中,保证机位视角就是监视器里那一帧:不裁切、不变形。
*
* 画幅比例框(ViewportAspectOverlay)只是构图参考线 + 导演视角截图裁切用,不影响这里的取景,
* 否则一换比例机位视角就会比监视器多看 / 少看一块,两边对不上。
*
* 返回 null 表示画布尺寸 / 传感器比例还没有有效值(首次布局前),调用方回落到整块画布即可。
*/
export function resolveCameraViewRenderRect(
canvasWidth: number,
canvasHeight: number,
sensorAspect: number
): CameraViewRenderRect | null {
if (!(canvasWidth > 0) || !(canvasHeight > 0) || !(sensorAspect > 0)) return null;
const canvasAspect = canvasWidth / canvasHeight;
const width = canvasAspect > sensorAspect ? canvasHeight * sensorAspect : canvasWidth;
const height = canvasAspect > sensorAspect ? canvasHeight : canvasWidth / sensorAspect;
return {
width,
height,
left: (canvasWidth - width) / 2,
top: (canvasHeight - height) / 2,
// 直接用传感器比例(而非 width / height),避免浮点误差让相机 aspect 与监视器差一丝
aspect: sensorAspect,
};
}

View File

@@ -0,0 +1,50 @@
import { Color, Scene } from "three";
import { describe, expect, it } from "vitest";
import { withoutSceneBackground } from "./viewportBokeh";
describe("withoutSceneBackground", () => {
it("hides the scene background while rendering (packed depth must not be overwritten)", () => {
const scene = new Scene();
scene.background = new Color(0x336699);
let backgroundDuringRender: Scene["background"] | "not-called" = "not-called";
const result = withoutSceneBackground(scene, () => {
backgroundDuringRender = scene.background;
return "rendered";
});
expect(backgroundDuringRender).toBeNull();
expect(result).toBe("rendered");
});
it("restores the original background afterwards", () => {
const scene = new Scene();
const background = new Color(0x336699);
scene.background = background;
withoutSceneBackground(scene, () => undefined);
expect(scene.background).toBe(background);
});
it("restores the background even when the render throws", () => {
const scene = new Scene();
const background = new Color(0x336699);
scene.background = background;
expect(() =>
withoutSceneBackground(scene, () => {
throw new Error("render failed");
})
).toThrow("render failed");
expect(scene.background).toBe(background);
});
it("keeps a scene without background background-less", () => {
const scene = new Scene();
withoutSceneBackground(scene, () => undefined);
expect(scene.background).toBeNull();
});
});

View File

@@ -4,6 +4,8 @@ import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js";
import { BokehPass } from "three/examples/jsm/postprocessing/BokehPass.js";
import { OutputPass } from "three/examples/jsm/postprocessing/OutputPass.js";
import { apertureToBokeh, type ResolvedLens } from "../schema/cameraGeometry";
import { BOKEH_MIN_TAPS, computeBokehUniforms } from "./bokehSettings";
import { configureAdaptiveBokeh } from "./adaptiveBokehShader";
/**
* 复用的景深后处理:用一份按 WebGLRenderer 缓存的 EffectComposer(RenderPass + BokehPass + OutputPass)
@@ -14,8 +16,55 @@ import { apertureToBokeh, type ResolvedLens } from "../schema/cameraGeometry";
* 再调用本函数,因此取景、焦距、虚化不会各写一套而漂移。
* 合成器按 gl 缓存(每个 Canvas 一份),尺寸 / 像素比变化时按需重建。
* 失败(如浏览器后处理不支持)则回退到普通渲染,画面永远可用。
*
* 景深参数(focus / aperture / maxblur / aspect / 采样点数)统一由 computeBokehUniforms 算;
* 采样点用自适应版本(configureAdaptiveBokeh);深度 pass 期间还会摘掉场景背景
* (withoutSceneBackground / makeDepthPassBackgroundSafe)—— 三条都是为了「三处看到的虚化一致」。
*/
/**
* 渲染期间临时摘掉 scene.background,渲完(含抛错)原样恢复。
*
* 为什么景深必须这么做 —— three 的一个坑,源码依据:
* 1. BokehPass 把「打包深度」(RGBADepthPacking) 写在渲染目标的**颜色**通道里(不是深度缓冲);
* 2. scene.background 是贴图时(全景图 / 贴图背景),three 会往同一个目标里再画一张全屏背景面:
* 它的材质是 `depthWrite:false` + `allowOverride:false`(见 WebGLBackground),看着"不影响深度",
* 但 **colorWrite 照样是开的** → 它一旦排在模型之后被画,就把整张打包深度覆盖成背景图的像素值;
* 3. 不透明物体的绘制顺序由 painterSortStable 决定,而它**先比 material.id**(为了少切状态)再比深度 ——
* 背景面材质 id 的大小完全取决于「全景图 / 贴图背景与模型谁先加载」,所以它既可能最先画(没事),
* 也可能最后画(把已经写好的深度整片冲掉)。
*
* 深度被冲掉之后,`getViewZ()` 解出来的距离全是垃圾(≈ 远平面)→ `factor` 巨大 → `dofblur` 直接
* 顶到 maxblur → **整幅画面(连合焦的主体)都按最大模糊糊成一片**,而背景贴图的明暗还会渗进"距离"里,
* 在渐变背景上留下横向条带。这正是「机位视角很糊、镜头监视器却正常」的成因:两个 Canvas 各自一份场景、
* 模型材质与背景材质的创建顺序不同,背景面排序结果就不一样。
*
* 背景早就被 RenderPass 画进颜色缓冲了,景深 depth pass 根本不需要它 —— 摘掉零副作用。
*/
export function withoutSceneBackground<T>(scene: { background: Scene["background"] }, render: () => T): T {
const background = scene.background;
scene.background = null;
try {
return render();
} finally {
scene.background = background;
}
}
/**
* 把 BokehPass 的 render 包一层「渲染期间不带场景背景」。
* 只影响它内部那次「overrideMaterial 深度渲染」,合成用的全屏四边形与颜色缓冲都不受影响。
*/
function makeDepthPassBackgroundSafe(pass: BokehPass, scene: Scene) {
const renderBokehPass = pass.render.bind(pass);
pass.render = ((...args: Parameters<BokehPass["render"]>) =>
withoutSceneBackground(scene, () => renderBokehPass(...args))) as BokehPass["render"];
return pass;
}
interface BokehState {
composer: EffectComposer;
bokeh: BokehPass;
@@ -28,6 +77,8 @@ interface BokehState {
const bokehCache = new WeakMap<WebGLRenderer, BokehState>();
const sizeVector = new Vector2();
/** 读取画布 CSS 尺寸(DOM 口径的 y 轴换算用),与 sizeVector 分开避免相互覆盖 */
const canvasSizeVector = new Vector2();
function readSize(gl: WebGLRenderer): { width: number; height: number; pixelRatio: number } {
gl.getSize(sizeVector);
@@ -66,6 +117,10 @@ function ensureBokehState(
aperture: apertureToBokeh(2.8),
maxblur: 0.012,
});
// 换成自适应采样的合成 shader(原版 41 个点按同心圆环摆,大画布上半径一大就看出重影)
configureAdaptiveBokeh(bokeh, BOKEH_MIN_TAPS);
// 深度 pass 期间摘掉背景贴图,否则打包深度会被背景面覆盖,虚化整幅饱和(见函数注释)
makeDepthPassBackgroundSafe(bokeh, scene);
composer.addPass(bokeh);
// OutputPass 负责把线性结果做色调映射 + sRGB 转换,否则经后处理的画面会偏暗/发灰
composer.addPass(new OutputPass());
@@ -80,19 +135,30 @@ function ensureBokehState(
/**
* 用景深后处理渲染一帧;lens 提供对焦距离 / 光圈 / 最大模糊量。
* viewportRect(CSS 像素)用于「机位视角」:那里把机位画面按传感器画幅居中遮幅渲染。
* viewportRect(CSS 像素,相对画布左上角,与 ViewportFrameRect / 画幅框同口径)用于「机位视角」:
* 那里把机位画面渲染到传感器画幅等比适配出来的矩形里。
* 监视器 / 导出本就按传感器画幅渲染;机位视角若按整个视口渲染,相机投影画幅更宽 →
* 水平视场角更大、物体被压得更窄,而虚化半径按画面宽度的固定比例(UV)不变 →
* 物体相对于自身看起来就更糊。传入 viewportRect 后,合成器按矩形(传感器画幅)尺寸渲染、
* 并经 setViewport/setScissor 把结果落到画布中央,让机位视角的取景 / 虚化与监视器逐像素一致。
* 物体相对于自身看起来就更糊。传入 viewportRect 后,合成器按矩形尺寸渲染、
* 并经 setViewport/setScissor 把结果落到该矩形,让机位视角的取景 / 虚化与监视器逐像素一致。
*/
export function renderSceneWithBokeh(
gl: WebGLRenderer,
scene: Scene,
camera: PerspectiveCamera,
lens: ResolvedLens,
viewportRect?: { x: number; y: number; width: number; height: number }
viewportRect?: { left: number; top: number; width: number; height: number },
options: { draft?: boolean } = {}
): void {
// 草稿档(拖动播放头时用):直接普通渲染,跳过「深度 pass + 41~96 点景深」整套。
// 拖动期间人看的是时间与构图,这里省掉的是最贵的那几笔(约 2~3 倍开销),松手立刻恢复完整景深。
// 特意没采用「拖动时降分辨率」:那会重建合成器的渲染目标(几十 MB 显存回收 + 重新分配),
// 反倒让每次开始拖动的头几帧卡一下。
if (options.draft) {
gl.render(scene, camera);
return;
}
const state = ensureBokehState(
gl,
scene,
@@ -104,20 +170,27 @@ export function renderSceneWithBokeh(
return;
}
// 三处(机位视角 / 监视器 / 导出)共用同一份口径算景深参数:focus / aperture / maxblur / aspect
// 只由 lens + 这块画布的尺寸决定;采样点数还会按模糊半径的设备像素数自适应加密。
const settings = computeBokehUniforms({
lens,
renderWidth: state.width,
renderHeight: state.height,
pixelRatio: state.pixelRatio,
});
configureAdaptiveBokeh(state.bokeh, settings.taps);
const uniforms = (state.bokeh as unknown as { uniforms?: Record<string, { value: number }> }).uniforms;
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;
// bokeh 的 aspect 必须等于渲染画幅(合成器尺寸的宽高比):纵向模糊偏移是 0.4 * aspect * dofblur,
// 画幅对了,虚化形状 / 强弱才和监视器一致。合成器是缓存复用的,每帧显式写避免残留。
if (uniforms.aspect) uniforms.aspect.value = state.width / state.height;
if (uniforms.focus) uniforms.focus.value = settings.focus;
if (uniforms.aperture) uniforms.aperture.value = settings.aperture;
if (uniforms.maxblur) uniforms.maxblur.value = settings.maxblur;
// aspect 必须等于渲染画幅(合成器尺寸的宽高比):竖直方向的扩散量按它缩放,
// 画幅对了虚化形状才和监视器一致。合成器是缓存复用的,每帧显式写避免残留。
if (uniforms.aspect) uniforms.aspect.value = settings.aspect;
}
// 有遮幅矩形时,把最终结果落到画布中央的矩形里(中间 pass 仍渲染到完整目标,不受影响)。
const pr = gl.getPixelRatio();
// 有遮幅矩形时,把最终结果落到画布上那个矩形里(中间 pass 仍渲染到完整目标,不受影响)。
const prevViewport = new Vector4();
const prevScissor = new Vector4();
let prevScissorTest = false;
@@ -125,8 +198,15 @@ export function renderSceneWithBokeh(
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);
// 两处换算:WebGL 视口原点在画布左下角,而传进来的矩形是 DOM 口径(左上角原点),y 要翻一次。
// 另外 setViewport / setScissor 收的是「逻辑像素」(CSS 像素),three 内部自己乘 pixelRatio;
// 这里若再乘一遍 dpr,遮幅会在高分屏上被放大 dpr 倍、并锚到画布左下角 ——
// 表现就是左边 / 下边留白、右边 / 上边被裁掉,机位画面显示不全。
const canvasSize = gl.getSize(canvasSizeVector);
const viewportLeft = viewportRect.left;
const viewportBottom = canvasSize.y - viewportRect.top - viewportRect.height;
gl.setViewport(viewportLeft, viewportBottom, viewportRect.width, viewportRect.height);
gl.setScissor(viewportLeft, viewportBottom, viewportRect.width, viewportRect.height);
gl.setScissorTest(true);
}
state.composer.render();

View File

@@ -1213,6 +1213,19 @@ export function TimelinePanel() {
const rulerRef = useRef<HTMLDivElement | null>(null);
const rulerRectRef = useRef<DOMRect | null>(null);
const [isScrubbing, setIsScrubbing] = useState(false);
// 拖动播放头:指针事件频率远高于刷新率(高回报率鼠标 / 触控板一秒几百次),
// 逐条 setPlayhead 会把帧预算全花在 React 重渲染上。只记最新位置,按帧应用一次。
const scrubClientXRef = useRef<number | null>(null);
const scrubFrameRef = useRef(0);
function cancelScrubFrame() {
if (scrubFrameRef.current === 0) return;
cancelAnimationFrame(scrubFrameRef.current);
scrubFrameRef.current = 0;
}
// 卸载时兜底:扔掉待执行的 scrub 帧,别在组件已经没了之后还去写 playhead
useEffect(() => () => cancelScrubFrame(), []);
const rulerTicks = useMemo(() => getTimelineRulerTicks(duration), [duration]);
const rulerMinorTicks = useMemo(
@@ -1961,6 +1974,10 @@ export function TimelinePanel() {
event.preventDefault();
setIsScrubbing(true);
setScrubbing(true);
// 拖动播放头期间同样冻结全站毛玻璃:面板 / 画幅遮罩都带 backdrop-filter,
// 它们背后是整张 WebGL 画面(机位视角下就是监视器那块大画面),只要被重绘就会
// 整块重算背景模糊 —— 这正是「拖动轨道卡死」的同一条原因,拖播放头一样躲不掉。
document.body.classList.add("is-timeline-dragging");
seekTo(time);
try {
@@ -1970,18 +1987,36 @@ export function TimelinePanel() {
}
}
/** 按帧应用一次拖动位置(指针事件可能一秒几百条,这里只保留最后一条) */
function applyScrubFrame() {
scrubFrameRef.current = 0;
const clientX = scrubClientXRef.current;
if (clientX === null) return;
const time = getTimeFromPointer(clientX);
if (time !== null) setPlayhead(time);
}
function handleRulerPointerMove(event: ReactPointerEvent<HTMLDivElement>) {
if (!isScrubbing) return;
const time = getTimeFromPointer(event.clientX);
if (time !== null) setPlayhead(time);
scrubClientXRef.current = event.clientX;
if (scrubFrameRef.current !== 0) return;
scrubFrameRef.current = requestAnimationFrame(applyScrubFrame);
}
function handleRulerPointerEnd(event: ReactPointerEvent<HTMLDivElement>) {
if (!isScrubbing) return;
// 松手前先把最后一帧的位置补上,避免丢掉最后一次移动
cancelScrubFrame();
if (scrubClientXRef.current !== null) {
const time = getTimeFromPointer(scrubClientXRef.current);
if (time !== null) setPlayhead(time);
scrubClientXRef.current = null;
}
setIsScrubbing(false);
setScrubbing(false);
document.body.classList.remove("is-timeline-dragging");
rulerRectRef.current = null;
try {
event.currentTarget.releasePointerCapture(event.pointerId);

View File

@@ -1,12 +1,10 @@
import { useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { Color, PerspectiveCamera as ThreePerspectiveCamera } from "three";
import { Color, PerspectiveCamera as ThreePerspectiveCamera, Vector2 } 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 时按关键帧自动切机位:当前应显示的机位由所有相机轨道里
@@ -18,14 +16,18 @@ import { withViewportCaptureHelpersHidden } from "../canvas/viewportCapture";
* 所以即便在导演视角拖动标尺,也会自动选中当前机位,而不是停在手动选中的机位。
* - 其它非播放(静止编辑)保持手动选中的机位,避免和「选中某机位编辑其关键帧」打架。
*
* 机位视角的渲染**与镜头监视器同一套做法**:先解析出「此刻显示的机位」→ 位姿 + 镜头,
* 再交给共享的 renderSceneWithBokeh 渲染(监视器 / 机位视角 / 视频导出三处同一份实现)。
* 位姿与镜头只算一次,不再由两个组件各算一遍。
* **机位画面不在主画布渲染**:那幅画面由镜头监视器那块 canvas 负责(机位视角下它顶到主视口的
* 画幅位置上),主画布这里只把整块清成场景背景色、留出遮幅。好处有两个:
* 1. 机位视角 / 监视器 / 导出是同一份渲染 → 取景与虚化不可能再各算一套而对不上;
* 2. 省掉主视口重复的一整套景深 composer + 深度 pass(大画布上这是最贵的一块)。
* 位姿 / 镜头仍然照常解析并写进渲染相机 —— 截图、拾取、导出的相机状态都依赖它。
*
* 用 priority=1 的 useFrame 接管渲染循环(priority>0 会关掉 r3f 的自动渲染,必须自己渲染)。
* 挂在 <Canvas> 内。
*/
const clearColor = new Color();
/** 读取画布 CSS 尺寸用(渲染矩形按 DOM 口径算) */
const canvasSizeVector = new Vector2();
export function ActiveCameraPlaybackSync({ activeCameraId }: { activeCameraId: string | null }) {
const { gl, scene } = useThree();
@@ -91,38 +93,22 @@ export function ActiveCameraPlaybackSync({ activeCameraId }: { activeCameraId: s
transition: cameraTransition,
fallbackCameraId: activeCameraId,
});
// 与镜头监视器严格一致:机位视角也按「传感器画幅」渲染。监视器(LensMonitorCamera)会把渲染相机的
// aspect 设成传感器比例;机位视角若按整个视口渲染,相机投影画幅更宽 → 水平视场角更大、
// 物体被压得更窄,而虚化半径按画面宽度的固定比例(UV)不变 → 物体相对自身看起来更糊。
// 所以这里把相机 aspect 设成传感器比例、把机位画面居中遮幅渲染(渲染矩形也是传感器画幅),
// 让取景 / 虚化与监视器逐像素一致。
// 机位画面**不再由主画布渲染**:机位视角下镜头监视器那块 canvas 会顶到主视口的画幅位置上,
// 显示的就是它的像素(同一份渲染 → 与监视器 / 导出天然一致)。主画布只需把整块清成场景
// 背景色,留出两侧的遮幅;省掉这里重复的一整套景深 composer + 深度 pass(之前发卡的主因)。
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 };
// 先把整个画布按背景色清成遮幅(黑边 / 背景色),再把机位画面渲进矩形里。
const canvasSize = gl.getSize(canvasSizeVector);
gl.setClearColor(clearColor.set(projectScene.backgroundColor), 1);
gl.setScissorTest(false);
gl.setViewport(0, 0, gl.domElement.width, gl.domElement.height);
// setViewport 收的是逻辑像素(CSS px),three 内部再乘 dpr;传设备像素会把视口放大 dpr 倍,
// 并把这个错误状态留给后续渲染(切回导演视角就会画面放大 / 被裁)。
gl.setViewport(0, 0, canvasSize.x, canvasSize.y);
gl.clear(true, true, false);
// 与镜头监视器 / 导出对齐:渲染前把编辑器辅助(视口网格、gizmo 红绿蓝轴、轨迹点 / 曲线、
// 地面坐标轴)藏起来 —— 监视器是 SceneRoot editorHelpers={false},一个都没有;这些东西留在
// 机位视角里既多出监视器没有的内容,又会写进 bokeh 的深度缓冲污染虚化(清晰平面 / 强弱对不上)。
withViewportCaptureHelpersHidden(scene, () => {
renderSceneWithBokeh(gl, scene, camera, lens, rect);
});
return;
}
}

View File

@@ -140,13 +140,6 @@ textarea:focus-visible {
font-family: "PingFang SC", "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.top-bar-title {
margin: 0;
font-size: 16px;
font-weight: 600;
line-height: 22px;
}
.top-bar-left,
.top-bar-center {
display: flex;
@@ -2327,6 +2320,14 @@ button.inspector-dropdown-option.is-selected {
mask-composite: exclude;
}
/* 机位视角:框外仍是相机画面(镜头监视器那块 canvas 铺满画布),只压暗、不磨砂 ——
磨砂会把相机画面整片糊掉,和监视器 / 导出对不上。 */
.viewport-aspect-mask.is-dim-only {
background: rgb(10 12 18 / 0.28);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.viewport-aspect-frame-shell {
position: absolute;
z-index: 1;
@@ -5248,6 +5249,30 @@ body.is-timeline-dragging * {
-webkit-backdrop-filter: blur(32px);
}
/* 机位视角:这块监视器直接顶到主视口那块画幅的位置上(left / top / width 由 JS 按画幅矩形给),
z-index 压到画幅遮罩(6)之下,画幅框 + 九宫格参考线才会画在相机画面之上;
同时去掉卡片那圈边框 / 圆角 / 磨砂 / 阴影 —— 它现在是「画面本身」而不是一块浮窗。 */
.lens-monitor.is-viewport-camera {
z-index: 5;
}
.lens-monitor.is-viewport-camera .lens-monitor-stage {
border: 0;
border-radius: 0;
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
/* 镜头参数小字改成压在画面底部的胶囊,免得把画幅顶掉 */
.lens-monitor.is-viewport-camera .lens-monitor-info {
position: absolute;
left: 50%;
bottom: 10px;
transform: translateX(-50%);
max-width: calc(100% - 24px);
}
/* 时间轴整条铺满视口宽度(左右只留和其它悬浮卡片一致的 16px 内缩),
左右对称 → 工具条中段自动落在屏幕正中;上下由 --floating-card-max-height 让位,不会和侧栏重叠 */

View File

@@ -57,7 +57,8 @@ it("matches the provided top bar and view switch dimensions", () => {
expect(css).toContain("grid-template-columns: var(--left-sidebar-width) minmax(0, 1fr) var(--right-sidebar-width);");
expect(css).toContain("min-height: 70px;");
expect(css).toMatch(/\.top-bar-title\s*\{[\s\S]*?font-size:\s*16px;[\s\S]*?line-height:\s*22px;/);
// 顶栏标题已去掉(只留中间的视角切换),别再被加回来
expect(css).not.toContain(".top-bar-title");
expect(css).toMatch(/\.mode-toggle\s*\{[\s\S]*?width:\s*212px;[\s\S]*?height:\s*44px;[\s\S]*?border-radius:\s*12px;/);
expect(css).toMatch(/\.mode-toggle-button\s*\{[\s\S]*?width:\s*100px;[\s\S]*?height:\s*36px;[\s\S]*?font-size:\s*14px;[\s\S]*?line-height:\s*20px;/);
expect(css).toMatch(/\.mode-toggle-button\[aria-pressed="true"\]\s*\{[\s\S]*?border-color:\s*rgb\(var\(--accent-rgb\) \/ 0\.28\);[\s\S]*?color:\s*rgb\(var\(--accent-rgb\)\);[\s\S]*?background:\s*rgb\(var\(--accent-rgb\) \/ 0\.12\);/);