fix: 优化空格播放和截图角色标签对不上

This commit is contained in:
2026-09-17 19:39:24 +08:00
parent 8d67f17a84
commit e66e3374bf
19 changed files with 633 additions and 58 deletions

View File

@@ -380,7 +380,9 @@ it("shares the scene transform with the ground plane so the grid never becomes a
render(<App />);
const grid = screen.getByTestId("viewport-grid");
const sceneGroup = grid.closest("group");
// 网格被包在「导出时隐藏」的 group 里,再外层才是与地面共享变换的场景 group
const gridWrapper = grid.closest("group");
const sceneGroup = gridWrapper?.parentElement?.closest("group");
// 网格与地面同在场景组内:共享场景平移/旋转/缩放,不会各自留在不同坐标空间
expect(sceneGroup).not.toBeNull();

View File

@@ -26,6 +26,7 @@ import { SceneRoot } from "./SceneRoot";
import { AXIS_COLORS } from "./sceneAxes";
import { GROUND_GRID_ELEVATION, GROUND_PLANE_SIZE, GROUND_SURFACE_SIDE } from "./sceneGround";
import { getSceneGroupTransform } from "./sceneTransform";
import { HIDE_FROM_VIEWPORT_CAPTURE_KEY, withViewportCaptureHelpersHidden } from "./viewportCapture";
import { PlaybackFrameDriver } from "../runtime/PlaybackFrameDriver";
import { ActiveCameraPlaybackSync } from "../runtime/ActiveCameraPlaybackSync";
import { PlaybackTransformSync } from "./PlaybackTransformSync";
@@ -69,7 +70,6 @@ const RIGHT_PANEL_WIDTH = 300;
const GIZMO_EDGE_PADDING = 20;
/** 悬浮卡片比面板自身宽度多占的留白(左/右各 --floating-inset,再加同宽间距) */
const FLOATING_PANEL_GAP = 32;
const HIDE_FROM_VIEWPORT_CAPTURE_KEY = "hideFromViewportCapture";
const CAPTURE_LABEL_FONT_SIZE = 12;
const CAPTURE_LABEL_HORIZONTAL_PADDING = 10;
const CAPTURE_LABEL_VERTICAL_PADDING = 6;
@@ -245,7 +245,7 @@ function getCharacterCaptureLabelY(item: DirectorObject) {
: getGroundedLabelY(item.bodyType);
}
function getViewportCaptureLabels() {
export function getViewportCaptureLabels(captureScene?: Object3D) {
const {
project: { objects, scene },
} = useDirectorStore.getState();
@@ -253,14 +253,15 @@ function getViewportCaptureLabels() {
if (!scene.showLabels) return [];
const sceneMatrix = createSceneMatrix(scene);
// 角色在场景里的真实世界坐标可能已被播放头 / 人物轨迹挪动,先刷新一遍矩阵,保证读到的位置是「当前这帧」的
if (captureScene && typeof captureScene.updateMatrixWorld === "function") captureScene.updateMatrixWorld(true);
return objects
.filter((item) => item.kind === "character" && item.visible)
.map((item): ViewportCaptureLabel => {
const objectMatrix = createTransformMatrix(item.transform);
const worldPosition = new Vector3(0, getCharacterCaptureLabelY(item), 0)
.applyMatrix4(objectMatrix)
.applyMatrix4(sceneMatrix);
// 优先用角色在场景里的真实世界坐标(播放头移动 / 人物轨迹会让它偏离静态 transform),
// 这样截图上标签才贴着角色头部,而不是落在它摆放时的 0 点坐标。
const worldPosition = findCharacterWorldPosition(captureScene, item) ?? computeCharacterLabelWorldPosition(item, sceneMatrix);
return {
text: item.name,
@@ -269,6 +270,28 @@ function getViewportCaptureLabels() {
});
}
function computeCharacterLabelWorldPosition(item: DirectorObject, sceneMatrix: Matrix4): Vector3 {
const objectMatrix = createTransformMatrix(item.transform);
return new Vector3(0, getCharacterCaptureLabelY(item), 0)
.applyMatrix4(objectMatrix)
.applyMatrix4(sceneMatrix);
}
/** 去场景里找这个角色的根 group(userData.directorObjectId === item.id),取它当前的世界坐标 + 标签高度 */
function findCharacterWorldPosition(captureScene: Object3D | undefined, item: DirectorObject): Vector3 | null {
if (!captureScene) return null;
let found: Object3D | undefined;
captureScene.traverse((object) => {
if (!found && object.userData?.directorObjectId === item.id) found = object;
});
if (!found) return null;
const position = found.getWorldPosition(new Vector3());
position.y += getCharacterCaptureLabelY(item);
return position;
}
function getCssRgbVariable(name: string, fallback: string) {
if (typeof window === "undefined") return fallback;
@@ -434,25 +457,6 @@ function captureViewportCanvas(
return cropCanvas.toDataURL("image/png");
}
function withViewportCaptureHelpersHidden(scene: Object3D, render: () => void) {
const hiddenObjects: Array<{ object: Object3D; visible: boolean }> = [];
scene.traverse((object) => {
if (object.userData?.[HIDE_FROM_VIEWPORT_CAPTURE_KEY]) {
hiddenObjects.push({ object, visible: object.visible });
object.visible = false;
}
});
try {
render();
} finally {
hiddenObjects.forEach(({ object, visible }) => {
object.visible = visible;
});
}
}
function CanvasCaptureBridge({
activeCamera,
bottomPadding,
@@ -507,7 +511,7 @@ function CanvasCaptureBridge({
label,
dataUrl: captureViewportCanvas(gl.domElement, viewportAspectRatio, bottomPadding, safeAreaInsets, {
camera: workingCamera,
labels: getViewportCaptureLabels(),
labels: getViewportCaptureLabels(scene),
}),
meta: buildScreenshotMeta({
mode: viewMode,
@@ -844,15 +848,19 @@ export function DirectorCanvas() {
// 网格和地面一样属于「场景内容」:共用同一套场景变换 + 同一尺寸,
// 变换不同会让两者错位,尺寸不同会让边缘不重合 —— 两种都会看成"两层地面"。
<group {...getSceneGroupTransform(sceneSettings)}>
<Grid
args={[GROUND_PLANE_SIZE, GROUND_PLANE_SIZE]}
cellThickness={0}
fadeDistance={80}
position={[0, sceneSettings.groundHeight + GROUND_GRID_ELEVATION, 0]}
sectionColor="#2A4065"
side={GROUND_SURFACE_SIDE}
userData={{ [HIDE_FROM_VIEWPORT_CAPTURE_KEY]: true }}
/>
{/* 导出时(截图 / 视频)是否把网格藏起来由 captureShowGrid 决定:
默认 false → 打上 hideFromViewportCapture,导出前统一藏起;勾选后不标记,网格留在画面里。
用一层 group 承载标记,避免依赖 <Grid> 自身是否转发 userData。 */}
<group userData={{ [HIDE_FROM_VIEWPORT_CAPTURE_KEY]: !sceneSettings.captureShowGrid }}>
<Grid
args={[GROUND_PLANE_SIZE, GROUND_PLANE_SIZE]}
cellThickness={0}
fadeDistance={80}
position={[0, sceneSettings.groundHeight + GROUND_GRID_ELEVATION, 0]}
sectionColor="#2A4065"
side={GROUND_SURFACE_SIDE}
/>
</group>
</group>
) : null}
{viewMode === "director" ? (
@@ -916,11 +924,6 @@ export function DirectorCanvas() {
</Suspense>
</Canvas>
</div>
{trajectoryPlacementObjectId ? (
<div className="viewport-placement-hint" role="status" aria-label="轨迹打点提示">
双击画布放置轨迹点 · Esc 或再次点击「添加轨迹」退出
</div>
) : null}
<ViewportAspectOverlay
bottomPadding={aspectOverlayBottomPadding}
onToggleRuleOfThirds={setViewportRuleOfThirdsEnabled}
@@ -938,6 +941,12 @@ export function DirectorCanvas() {
重置视角
</button>
</div>
{/* 轨迹打点提示:独立浮层,显示在快捷工具栏「上方」,不嵌进工具栏里 */}
{trajectoryPlacementObjectId ? (
<div className="viewport-placement-hint" role="status" aria-label="轨迹打点提示">
双击画布放置轨迹点 · Esc 或再次点击「添加轨迹」退出
</div>
) : null}
<ViewportToolbar getViewportCameraSnapshot={getViewportCameraSnapshot} toolbarContainerRef={toolbarRef} />
</div>
);

View File

@@ -1,4 +1,5 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { readFileSync } from "node:fs";
import { Box3, DoubleSide, Vector3 } from "three";
import { afterEach, beforeEach, vi } from "vitest";
import { VIEWPORT_CAMERA_VISUAL_SCALE } from "../schema/cameraGeometry";
@@ -274,6 +275,16 @@ it("uses the requested dark ground surface color", () => {
expect(groundMaterial?.outerHTML.toLowerCase()).toContain("polygonoffsetunits=\"1\"");
});
it("paints the ground with the ground color from the scene settings", () => {
useDirectorStore.getState().updateScene({ groundColor: "#204060" });
const { container } = render(<SceneRoot />);
// 地面颜色和天空颜色一样是工程设置:面板上改了颜色,视口里的地面材质就得跟着变
expect(container.querySelector('meshbasicmaterial[color="#204060"]')).toBeInTheDocument();
expect(container.querySelector('meshbasicmaterial[color="#303640"]')).not.toBeInTheDocument();
});
it("keeps the ground visible when the view drops below the ground plane", () => {
const { container } = render(<SceneRoot />);
const groundMaterial = container.querySelector('meshbasicmaterial[color="#303640"]');
@@ -680,6 +691,21 @@ it("renders solid trajectory spheres and a smooth curve through the waypoints",
expect(container.querySelectorAll('mesh[name^="trajectory-point-"]')).toHaveLength(2);
});
it("keeps the trajectory marker material writing depth so an opaque ground cannot hide it", () => {
// 这条约束落在材质参数上(jsdom 里 <mesh> / <meshBasicMaterial> 只是普通 DOM 节点,
// depthWrite 这种 unknown 布尔属性不会落到 DOM 上,跑不出来),所以按源码契约断言。
const source = readFileSync("src/editor/canvas/SceneRoot.tsx", "utf8");
const markerMaterialLine = source
.split("\n")
.find((line) => line.includes("<meshBasicMaterial color={selected ? TRAJECTORY_SELECTED_COLOR : color}"));
expect(markerMaterialLine).toBeDefined();
// 地面是 transparent 材质:即使透明度为 1 也走透明通道、排在所有不透明物体之后渲染。
// 小球一旦不写深度(depthWrite={false}),地面会整片盖住它 ——
// 表现就是「地面透明度为 1 时看不到轨迹点」。
expect(markerMaterialLine).not.toContain("depthWrite");
});
it("puts a walking character at its trajectory sample for the current playhead", () => {
useDirectorStore.getState().addTrajectoryPoint("char_default_a", [8, 0, 0]);
// 单点轨迹:0s 在角色起始位置,时间轴末尾(5s)到达该轨迹点

View File

@@ -41,6 +41,7 @@ import { getEffectiveGroundOpacity } from "./panoramaMath";
import { getGroundAxisLines } from "./sceneAxes";
import { GROUND_GRID_ELEVATION, GROUND_PLANE_SIZE, GROUND_SURFACE_SIDE } from "./sceneGround";
import { getSceneGroupTransform } from "./sceneTransform";
import { HIDE_FROM_VIEWPORT_CAPTURE_KEY } from "./viewportCapture";
import { getCrowdAnchorTransform } from "../store/directorStore";
export { getEffectiveGroundOpacity, getPanoramaRotationRadians } from "./panoramaMath";
@@ -55,7 +56,6 @@ const VIEWPORT_CAMERA_LINE_OPACITY = 0.92;
const VIEWPORT_CAMERA_HIT_PADDING = 0.06;
const VIEWPORT_CAMERA_FORWARD = new Vector3(0, 0, 1);
const VIEWPORT_CAMERA_WORLD_UP = new Vector3(0, 1, 0);
const HIDE_FROM_VIEWPORT_CAPTURE_KEY = "hideFromViewportCapture";
const VIEWPORT_CAMERA_BODY_CENTER: CameraWirePoint = [0, 0, -0.52 * VIEWPORT_CAMERA_VISUAL_SCALE];
const VIEWPORT_CAMERA_BODY_SIZE: CameraWirePoint = [
0.4 * VIEWPORT_CAMERA_VISUAL_SCALE,
@@ -967,14 +967,17 @@ function TrajectoryPath({
<circleGeometry args={[0.3, 24]} />
<meshBasicMaterial depthWrite={false} opacity={0} transparent />
</mesh>
{/* 实心小球:选中用高亮色,否则用角色色 */}
{/* 实心小球:选中用高亮色,否则用角色色。
这里必须写深度(用材质默认值 true,别改成 false):地面是 transparent 材质,
即使透明度为 1 也走透明通道、排在所有不透明物体之后渲染;
小球一旦不写深度,地面就会整片盖住它 —— 表现就是「地面不透明时看不到轨迹点」。 */}
<mesh
name={`trajectory-point-${point.id}`}
userData={{ [HIDE_FROM_VIEWPORT_CAPTURE_KEY]: true }}
onClick={interactive ? (event) => selectPoint(point.id, event) : undefined}
>
<sphereGeometry args={[0.1, 18, 14]} />
<meshBasicMaterial color={selected ? TRAJECTORY_SELECTED_COLOR : color} depthWrite={false} />
<meshBasicMaterial color={selected ? TRAJECTORY_SELECTED_COLOR : color} />
</mesh>
</group>
);
@@ -1073,7 +1076,7 @@ export function SceneRoot() {
<mesh position={[0, scene.groundHeight, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[GROUND_PLANE_SIZE, GROUND_PLANE_SIZE]} />
<meshBasicMaterial
color="#303640"
color={scene.groundColor}
opacity={getEffectiveGroundOpacity(scene.groundOpacity, Boolean(panoramaAsset))}
polygonOffset
polygonOffsetFactor={1}

View File

@@ -1,5 +1,5 @@
import { expect, it } from "vitest";
import type { SceneSettings } from "../schema/directorProject";
import { DEFAULT_GROUND_COLOR, type SceneSettings } from "../schema/directorProject";
import { getSceneGroupTransform } from "./sceneTransform";
function createScene(patch: Partial<SceneSettings> = {}): SceneSettings {
@@ -11,8 +11,10 @@ function createScene(patch: Partial<SceneSettings> = {}): SceneSettings {
panoramaYaw: 0,
panoramaRadius: 60,
showLabels: true,
captureShowGrid: false,
snapToGrid: false,
showGround: true,
groundColor: DEFAULT_GROUND_COLOR,
groundOpacity: 0.4,
groundHeight: 0,
...patch,

View File

@@ -0,0 +1,56 @@
import { expect, it } from "vitest";
import { Group, Object3D } from "three";
import {
HIDE_FROM_VIEWPORT_CAPTURE_KEY,
hideViewportCaptureHelpers,
withViewportCaptureHelpersHidden,
} from "./viewportCapture";
function makeFlaggedObject() {
const object = new Object3D();
object.userData[HIDE_FROM_VIEWPORT_CAPTURE_KEY] = true;
return object;
}
it("hides every flagged helper and restores the original visibility afterwards", () => {
const root = new Group();
const flagged = makeFlaggedObject();
const nestedFlagged = makeFlaggedObject();
const kept = new Object3D();
const nestedKept = new Object3D();
flagged.add(nestedFlagged, nestedKept);
root.add(flagged, kept);
// 先藏起来一个,验证 restore 还原的是「进入前的可见性」而不是无脑设 true
flagged.visible = false;
const restore = hideViewportCaptureHelpers(root);
expect(flagged.visible).toBe(false); // 本来就隐藏,hider 不该改它
expect(nestedFlagged.visible).toBe(false);
expect(kept.visible).toBe(true);
expect(nestedKept.visible).toBe(true);
restore();
expect(flagged.visible).toBe(false); // 还原回 hider 进入前的状态
expect(nestedFlagged.visible).toBe(true);
expect(kept.visible).toBe(true);
expect(nestedKept.visible).toBe(true);
});
it("hides helpers only for the render call and always restores, even when render throws", () => {
const root = new Group();
const flagged = makeFlaggedObject();
root.add(flagged);
const seenDuringRender: boolean[] = [];
expect(() =>
withViewportCaptureHelpersHidden(root, () => {
seenDuringRender.push(flagged.visible);
throw new Error("render blew up");
})
).toThrow("render blew up");
expect(seenDuringRender).toEqual([false]);
expect(flagged.visible).toBe(true);
});

View File

@@ -0,0 +1,44 @@
import type { Object3D } from "three";
/**
* 截图 / 视频导出时要从画面里去掉的编辑器辅助元素。
*
* 约定:凡是「只在编辑器里给用户看」的东西 —— 视口网格、地面 X/Z 坐标轴、人物轨迹与路点、
* 机位线框与拾取盒、拖拽 gizmo(那三条红绿蓝轴)、名字标签……都在 userData 里打上这个标记。
*
* 截图与视频导出必须在渲染前统一把它们隐藏、渲完立刻恢复,并且**共用这一份实现**:
* 两边各写一套时,很容易出现「截图干净、导出的视频里却带着 gizmo 红绿蓝轴」这类不一致。
*/
export const HIDE_FROM_VIEWPORT_CAPTURE_KEY = "hideFromViewportCapture";
/**
* 隐藏场景里所有带标记的辅助对象,返回「恢复原样」的函数。
*
* 适合需要跨多帧保持隐藏的场合(如实时录屏兜底路径,整段录制期间都不能露出来)。
*/
export function hideViewportCaptureHelpers(scene: Object3D): () => void {
const hiddenObjects: Array<{ object: Object3D; visible: boolean }> = [];
scene.traverse((object) => {
if (object.userData?.[HIDE_FROM_VIEWPORT_CAPTURE_KEY]) {
hiddenObjects.push({ object, visible: object.visible });
object.visible = false;
}
});
return () => {
hiddenObjects.forEach(({ object, visible }) => {
object.visible = visible;
});
};
}
/** 藏起辅助元素后渲染一帧;无论渲染成功还是抛错,都把可见性恢复原状。 */
export function withViewportCaptureHelpersHidden(scene: Object3D, render: () => void) {
const restore = hideViewportCaptureHelpers(scene);
try {
render();
} finally {
restore();
}
}

View File

@@ -0,0 +1,68 @@
import { Group, Scene } from "three";
import { beforeEach, describe, expect, it } from "vitest";
import type { DirectorObject } from "../schema/directorProject";
import { createDefaultDirectorProject, useDirectorStore } from "../store/directorStore";
import { getViewportCaptureLabels } from "./DirectorCanvas";
describe("getViewportCaptureLabels", () => {
beforeEach(() => {
useDirectorStore.getState().replaceProject(createDefaultDirectorProject());
});
it("places each label at the character's real scene position, not its static transform (0,0,0)", () => {
const project = createDefaultDirectorProject();
const characters = project.objects.filter((o): o is DirectorObject => o.kind === "character");
expect(characters.length).toBeGreaterThan(0);
// 把所有角色静态 transform 摆到原点,但在场景里把它们挪到别处 ——
// 截图标签必须贴着「场景里真实的位置」,而不是落回 0 点坐标。
const scene = new Scene();
const nameToPosition = new Map<string, [number, number, number]>();
characters.forEach((item, index) => {
const group = new Group();
group.userData.directorObjectId = item.id;
const position: [number, number, number] = [10 + index, 0.5, 20 + index];
group.position.set(position[0], position[1], position[2]);
scene.add(group);
nameToPosition.set(item.name, position);
item.transform.position = [0, 0, 0];
});
useDirectorStore.getState().replaceProject(project);
const labels = getViewportCaptureLabels(scene);
expect(labels.length).toBe(characters.length);
for (const label of labels) {
const expected = nameToPosition.get(label.text);
expect(expected).toBeTruthy();
// 用 x / z 验证:若误用静态 transform(原点),这里会是 0
expect(label.worldPosition.x).toBeCloseTo(expected![0], 5);
expect(label.worldPosition.z).toBeCloseTo(expected![2], 5);
// y 在角色头部高度(基础 y + 标签高度),应高于场景里摆放的 y
expect(label.worldPosition.y).toBeGreaterThan(expected![1]);
}
});
it("returns nothing when scene.showLabels is off", () => {
const project = createDefaultDirectorProject();
project.scene.showLabels = false;
useDirectorStore.getState().replaceProject(project);
expect(getViewportCaptureLabels(new Scene())).toEqual([]);
});
});
describe("captureShowGrid setting", () => {
beforeEach(() => {
useDirectorStore.getState().replaceProject(createDefaultDirectorProject());
});
it("defaults to false (grid hidden during capture) and toggles", () => {
expect(useDirectorStore.getState().project.scene.captureShowGrid).toBe(false);
useDirectorStore.getState().updateScene({ captureShowGrid: true });
expect(useDirectorStore.getState().project.scene.captureShowGrid).toBe(true);
});
});