Compare commits
3 Commits
fix_blur
...
07d9e5e798
| Author | SHA1 | Date | |
|---|---|---|---|
| 07d9e5e798 | |||
| 5cc2e26a29 | |||
| f778f05957 |
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||||
import { PerspectiveCamera as DreiPerspectiveCamera } from "@react-three/drei";
|
||||
import { useEffect, 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";
|
||||
import { setMonitorRenderContext } from "../io/monitorRenderBridge";
|
||||
import { SceneRoot } from "./SceneRoot";
|
||||
import { ViewportBackground } from "./ViewportBackground";
|
||||
import { PlaybackTransformSync } from "./PlaybackTransformSync";
|
||||
import { renderSceneWithBokeh } from "./viewportBokeh";
|
||||
import { resolveCameraViewRenderRect } from "./viewportAspectFrame";
|
||||
import {
|
||||
formatLensInfo,
|
||||
getCameraRenderQuaternion,
|
||||
@@ -20,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({
|
||||
@@ -37,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();
|
||||
@@ -54,6 +60,12 @@ 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);
|
||||
// playhead 自己订阅(而不是从外层当 prop 传进来):外壳组件不必因为这个每秒变几十次的值
|
||||
// 去 reconcile 整棵场景树(见 LensMonitorCanvas 的 memo)。
|
||||
const playhead = usePlaybackStore((state) => state.playhead);
|
||||
// 拖动播放头期间走草稿档:跳过景深整套后处理,把帧预算留给「跟手」
|
||||
const isScrubbing = usePlaybackStore((state) => state.isScrubbing);
|
||||
// 关键帧感知:拖播放头时,监视器上的对焦距离 / 光圈 / 焦段 / 景深强度都按关键帧插值,
|
||||
// 让「移焦 / 渐入渐出虚化 / 变焦」在监视器上所见即所得(与导出管线一致的 resolveLensAtTime)。
|
||||
const lens = useMemo(
|
||||
@@ -84,6 +96,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,10 +121,15 @@ function LensMonitorCamera({
|
||||
renderCamera.updateProjectionMatrix();
|
||||
camRef.current = renderCamera;
|
||||
|
||||
// 导出期间由 videoExport 在这块画布上逐帧渲染(同一颗 gl / 同一个场景 / 同一颗相机),
|
||||
// 这里跳过,避免和导出循环双重渲染、也避免争用同一份景深合成器缓存。
|
||||
if (isExporting) return;
|
||||
|
||||
// 与主视口 / 视频导出共用同一份景深渲染(renderSceneWithBokeh):监视器不再自建一套
|
||||
// EffectComposer —— 两套实现很容易漂移,导出与监视器的取景 / 虚化就会对不上。
|
||||
// 合成器按 gl 缓存、画布尺寸变化会自动重建;不支持后处理时回退普通渲染,监视器永远可用。
|
||||
renderSceneWithBokeh(gl, scene, renderCamera, lens);
|
||||
// 拖动播放头时传 draft:跳过整套景深后处理(松手立刻恢复),拖动才跟手。
|
||||
renderSceneWithBokeh(gl, scene, renderCamera, lens, undefined, { draft: isScrubbing });
|
||||
}, 1);
|
||||
|
||||
return (
|
||||
@@ -117,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°,避免越过天顶后镜头翻转 */
|
||||
@@ -124,70 +215,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,21 +228,82 @@ 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),点击/拖到关键帧时数字同步刷新
|
||||
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;
|
||||
@@ -293,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}
|
||||
@@ -305,45 +413,15 @@ 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 }}
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
102
src/editor/canvas/adaptiveBokehShader.ts
Normal file
102
src/editor/canvas/adaptiveBokehShader.ts
Normal 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;
|
||||
}
|
||||
116
src/editor/canvas/bokehSettings.test.ts
Normal file
116
src/editor/canvas/bokehSettings.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
95
src/editor/canvas/bokehSettings.ts
Normal file
95
src/editor/canvas/bokehSettings.ts
Normal 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)),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
50
src/editor/canvas/viewportBokeh.test.ts
Normal file
50
src/editor/canvas/viewportBokeh.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
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";
|
||||
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);
|
||||
@@ -37,13 +88,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 +102,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, {
|
||||
@@ -66,10 +117,14 @@ 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());
|
||||
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,26 +133,86 @@ function ensureBokehState(
|
||||
}
|
||||
}
|
||||
|
||||
/** 用景深后处理渲染一帧;lens 提供对焦距离 / 光圈 / 最大模糊量。 */
|
||||
/**
|
||||
* 用景深后处理渲染一帧;lens 提供对焦距离 / 光圈 / 最大模糊量。
|
||||
* viewportRect(CSS 像素,相对画布左上角,与 ViewportFrameRect / 画幅框同口径)用于「机位视角」:
|
||||
* 那里把机位画面渲染到传感器画幅等比适配出来的矩形里。
|
||||
* 监视器 / 导出本就按传感器画幅渲染;机位视角若按整个视口渲染,相机投影画幅更宽 →
|
||||
* 水平视场角更大、物体被压得更窄,而虚化半径按画面宽度的固定比例(UV)不变 →
|
||||
* 物体相对于自身看起来就更糊。传入 viewportRect 后,合成器按矩形尺寸渲染、
|
||||
* 并经 setViewport/setScissor 把结果落到该矩形,让机位视角的取景 / 虚化与监视器逐像素一致。
|
||||
*/
|
||||
export function renderSceneWithBokeh(
|
||||
gl: WebGLRenderer,
|
||||
scene: Scene,
|
||||
camera: PerspectiveCamera,
|
||||
lens: ResolvedLens
|
||||
lens: ResolvedLens,
|
||||
viewportRect?: { left: number; top: number; width: number; height: number },
|
||||
options: { draft?: boolean } = {}
|
||||
): void {
|
||||
const state = ensureBokehState(gl, scene, camera);
|
||||
// 草稿档(拖动播放头时用):直接普通渲染,跳过「深度 pass + 41~96 点景深」整套。
|
||||
// 拖动期间人看的是时间与构图,这里省掉的是最贵的那几笔(约 2~3 倍开销),松手立刻恢复完整景深。
|
||||
// 特意没采用「拖动时降分辨率」:那会重建合成器的渲染目标(几十 MB 显存回收 + 重新分配),
|
||||
// 反倒让每次开始拖动的头几帧卡一下。
|
||||
if (options.draft) {
|
||||
gl.render(scene, camera);
|
||||
return;
|
||||
}
|
||||
|
||||
const state = ensureBokehState(
|
||||
gl,
|
||||
scene,
|
||||
camera,
|
||||
viewportRect ? { width: viewportRect.width, height: viewportRect.height, pixelRatio: gl.getPixelRatio() } : undefined
|
||||
);
|
||||
if (!state) {
|
||||
gl.render(scene, camera);
|
||||
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;
|
||||
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 prevViewport = new Vector4();
|
||||
const prevScissor = new Vector4();
|
||||
let prevScissorTest = false;
|
||||
if (viewportRect) {
|
||||
gl.getViewport(prevViewport);
|
||||
gl.getScissor(prevScissor);
|
||||
prevScissorTest = gl.getScissorTest();
|
||||
// 两处换算: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();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useRef } from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { 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";
|
||||
|
||||
/**
|
||||
* 播放 / 导出 / 拖动标尺 scrub 时按关键帧自动切机位:当前应显示的机位由所有相机轨道里
|
||||
@@ -17,13 +16,19 @@ import { renderSceneWithBokeh } from "../canvas/viewportBokeh";
|
||||
* 所以即便在导演视角拖动标尺,也会自动选中当前机位,而不是停在手动选中的机位。
|
||||
* - 其它非播放(静止编辑)保持手动选中的机位,避免和「选中某机位编辑其关键帧」打架。
|
||||
*
|
||||
* 机位视角的渲染**与镜头监视器同一套做法**:先解析出「此刻显示的机位」→ 位姿 + 镜头,
|
||||
* 再交给共享的 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();
|
||||
const camera = useThree((state) => state.camera) as ThreePerspectiveCamera;
|
||||
@@ -88,7 +93,22 @@ export function ActiveCameraPlaybackSync({ activeCameraId }: { activeCameraId: s
|
||||
transition: cameraTransition,
|
||||
fallbackCameraId: activeCameraId,
|
||||
});
|
||||
renderSceneWithBokeh(gl, scene, camera, resolveLensAtTime(shot, playhead, cameraTracks));
|
||||
// 机位画面**不再由主画布渲染**:机位视角下镜头监视器那块 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();
|
||||
}
|
||||
const canvasSize = gl.getSize(canvasSizeVector);
|
||||
gl.setClearColor(clearColor.set(projectScene.backgroundColor), 1);
|
||||
gl.setScissorTest(false);
|
||||
// setViewport 收的是逻辑像素(CSS px),three 内部再乘 dpr;传设备像素会把视口放大 dpr 倍,
|
||||
// 并把这个错误状态留给后续渲染(切回导演视角就会画面放大 / 被裁)。
|
||||
gl.setViewport(0, 0, canvasSize.x, canvasSize.y);
|
||||
gl.clear(true, true, false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -3618,6 +3619,11 @@ button.inspector-dropdown-option.is-selected {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* 机位视角导出走监视器那块画布:同样在导出期间切到导出分辨率,保持比例不变形 */
|
||||
.lens-monitor-canvas.is-video-exporting {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* ---- 工具条:左=时间信息,中=播放控制 + 关键帧操作,右=导出 ----
|
||||
三栏等宽(1fr auto 1fr):中间一组永远落在整条工具条的正中,
|
||||
不会因为左右两组宽度不同而偏移。 */
|
||||
@@ -5243,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 让位,不会和侧栏重叠 */
|
||||
|
||||
|
||||
@@ -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\);/);
|
||||
|
||||
Reference in New Issue
Block a user