feat: 3D 编辑器支持 - 网格、相机、Gizmo

This commit is contained in:
yhh
2025-12-22 12:40:43 +08:00
parent a1e1189f9d
commit 66d9f428b3
18 changed files with 3646 additions and 50 deletions

View File

@@ -2,7 +2,7 @@ import { useEffect, useRef, useState, useCallback } from 'react';
import {
RotateCcw, Maximize2, Grid3x3, Eye, EyeOff, Activity,
MousePointer2, Move, RotateCw, Scaling, Globe, QrCode, ChevronDown,
Magnet, ZoomIn, Save, X, PackageOpen
Magnet, ZoomIn, Save, X, PackageOpen, Box, Square
} from 'lucide-react';
import '../styles/Viewport.css';
import { useEngine } from '../hooks/useEngine';
@@ -296,6 +296,24 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
const isDraggingCameraRef = useRef(false);
const isDraggingTransformRef = useRef(false);
const lastMousePosRef = useRef({ x: 0, y: 0 });
// 3D Camera state (orbit camera)
// 3D 相机状态(轨道相机)
const [renderMode, setRenderMode] = useState<'2D' | '3D'>('2D');
const renderModeRef = useRef<'2D' | '3D'>('2D');
// Orbit camera parameters: distance from target, pitch (vertical), yaw (horizontal)
// 轨道相机参数:距目标距离、俯仰角(垂直)、偏航角(水平)
const [orbitCamera, setOrbitCamera] = useState({
distance: 10, // Distance from target | 距目标距离
pitch: -30, // Vertical angle in degrees (-90 to 90) | 垂直角度(度)
yaw: 45, // Horizontal angle in degrees | 水平角度(度)
targetX: 0, // Target point X | 目标点 X
targetY: 0, // Target point Y | 目标点 Y
targetZ: 0 // Target point Z | 目标点 Z
});
const orbitCameraRef = useRef(orbitCamera);
const isOrbitingRef = useRef(false);
const isPanningRef = useRef(false);
const selectedEntityRef = useRef<Entity | null>(null);
const messageHubRef = useRef<MessageHub | null>(null);
const commandManagerRef = useRef<CommandManager | null>(null);
@@ -319,7 +337,9 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
gridSnapRef.current = gridSnapValue;
rotationSnapRef.current = rotationSnapValue;
scaleSnapRef.current = scaleSnapValue;
}, [playState, camera2DZoom, camera2DOffset, transformMode, snapEnabled, gridSnapValue, rotationSnapValue, scaleSnapValue]);
renderModeRef.current = renderMode;
orbitCameraRef.current = orbitCamera;
}, [playState, camera2DZoom, camera2DOffset, transformMode, snapEnabled, gridSnapValue, rotationSnapValue, scaleSnapValue, renderMode, orbitCamera]);
// 发布 Play 状态变化事件,用于层级面板实时同步
// Publish play state change event for hierarchy panel real-time sync
@@ -348,6 +368,38 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
return Math.round(value / scaleSnapRef.current) * scaleSnapRef.current;
}, []);
// Calculate 3D camera position from orbit parameters
// 从轨道参数计算 3D 相机位置
const calculateOrbitCameraPosition = useCallback((orbit: typeof orbitCamera) => {
const pitchRad = (orbit.pitch * Math.PI) / 180;
const yawRad = (orbit.yaw * Math.PI) / 180;
// Convert spherical to Cartesian coordinates
// 将球坐标转换为笛卡尔坐标
// Note: negative pitch means looking down, so camera should be above target
// 注意:负的 pitch 表示向下看,所以相机应该在目标上方
const x = orbit.targetX + orbit.distance * Math.cos(pitchRad) * Math.sin(yawRad);
const y = orbit.targetY - orbit.distance * Math.sin(pitchRad); // Negate for correct direction
const z = orbit.targetZ + orbit.distance * Math.cos(pitchRad) * Math.cos(yawRad);
return { x, y, z };
}, []);
// Sync orbit camera to engine
// 同步轨道相机到引擎
const syncOrbitCameraToEngine = useCallback((orbit: typeof orbitCamera) => {
const pos = calculateOrbitCameraPosition(orbit);
const engineService = EngineService.getInstance();
// Set camera position
// 设置相机位置
engineService.setCamera3DPosition(pos.x, pos.y, pos.z);
// Make camera look at target
// 让相机看向目标
engineService.camera3DLookAt(orbit.targetX, orbit.targetY, orbit.targetZ);
}, [calculateOrbitCameraPosition]);
// Screen to world coordinate conversion - uses refs to avoid re-registering event handlers
const screenToWorld = useCallback((screenX: number, screenY: number) => {
const canvas = canvasRef.current;
@@ -438,6 +490,30 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
return;
}
// 3D mode: orbit camera controls
// 3D 模式:轨道相机控制
if (renderModeRef.current === '3D') {
if (e.button === 0) {
// Left button: orbit (rotate around target)
// 左键:轨道旋转(围绕目标旋转)
isOrbitingRef.current = true;
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
canvas.style.cursor = 'grabbing';
e.preventDefault();
} else if (e.button === 1 || e.button === 2) {
// Middle/Right button: pan
// 中键/右键:平移
isPanningRef.current = true;
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
canvas.style.cursor = 'move';
e.preventDefault();
}
return;
}
// 2D mode: original camera controls
// 2D 模式:原始相机控制
// Middle mouse button (1) or right button (2) for camera pan
if (e.button === 1 || e.button === 2) {
isDraggingCameraRef.current = true;
@@ -532,6 +608,54 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
const deltaX = e.clientX - lastMousePosRef.current.x;
const deltaY = e.clientY - lastMousePosRef.current.y;
// 3D mode: orbit camera controls
// 3D 模式:轨道相机控制
if (renderModeRef.current === '3D') {
if (isOrbitingRef.current) {
// Orbit: rotate around target
// 轨道:围绕目标旋转
const orbitSensitivity = 0.3;
setOrbitCamera((prev) => {
const newYaw = prev.yaw + deltaX * orbitSensitivity;
const newPitch = Math.max(-89, Math.min(89, prev.pitch - deltaY * orbitSensitivity));
const newOrbit = { ...prev, yaw: newYaw, pitch: newPitch };
// Sync to engine in next tick
// 在下一帧同步到引擎
requestAnimationFrame(() => syncOrbitCameraToEngine(newOrbit));
return newOrbit;
});
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
} else if (isPanningRef.current) {
// Pan: move target point
// 平移:移动目标点
const panSensitivity = 0.01;
const orbit = orbitCameraRef.current;
const yawRad = (orbit.yaw * Math.PI) / 180;
// Calculate pan direction based on camera orientation
// 根据相机朝向计算平移方向
const rightX = Math.cos(yawRad);
const rightZ = -Math.sin(yawRad);
setOrbitCamera((prev) => {
const panScale = prev.distance * panSensitivity;
const newOrbit = {
...prev,
targetX: prev.targetX - deltaX * rightX * panScale,
targetY: prev.targetY + deltaY * panScale,
targetZ: prev.targetZ - deltaX * rightZ * panScale
};
requestAnimationFrame(() => syncOrbitCameraToEngine(newOrbit));
return newOrbit;
});
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
}
return;
}
// 2D mode: original camera controls
// 2D 模式:原始相机控制
if (isDraggingCameraRef.current) {
// Camera pan - use ref to avoid stale closure
const dpr = window.devicePixelRatio || 1;
@@ -625,6 +749,19 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
};
const handleMouseUp = () => {
// 3D mode: reset orbit/pan flags
// 3D 模式:重置轨道/平移标志
if (isOrbitingRef.current) {
isOrbitingRef.current = false;
canvas.style.cursor = 'grab';
}
if (isPanningRef.current) {
isPanningRef.current = false;
canvas.style.cursor = 'grab';
}
// 2D mode: original mouse up handling
// 2D 模式:原始鼠标抬起处理
if (isDraggingCameraRef.current) {
isDraggingCameraRef.current = false;
canvas.style.cursor = 'grab';
@@ -698,6 +835,22 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
if (playStateRef.current === 'playing') {
return;
}
// 3D mode: zoom by changing distance
// 3D 模式:通过改变距离来缩放
if (renderModeRef.current === '3D') {
const zoomFactor = e.deltaY > 0 ? 1.1 : 0.9;
setOrbitCamera((prev) => {
const newDistance = Math.max(1, Math.min(1000, prev.distance * zoomFactor));
const newOrbit = { ...prev, distance: newDistance };
requestAnimationFrame(() => syncOrbitCameraToEngine(newOrbit));
return newOrbit;
});
return;
}
// 2D mode: original zoom handling
// 2D 模式:原始缩放处理
// Use multiplicative zoom for consistent feel across all zoom levels
// 使用乘法缩放,在所有缩放级别都有一致的感觉
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
@@ -748,13 +901,52 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
}
}, [camera2DOffset, camera2DZoom, engine.state.initialized]);
// Sync render mode and 3D camera to engine
// 同步渲染模式和 3D 相机到引擎
useEffect(() => {
if (engine.state.initialized) {
const engineService = EngineService.getInstance();
const mode = renderMode === '3D' ? 1 : 0;
engineService.setRenderMode(mode);
if (renderMode === '3D') {
// Initialize 3D camera when switching to 3D mode
// 切换到 3D 模式时初始化 3D 相机
engineService.setCamera3DProjection(0); // Perspective
engineService.setCamera3DFov(60);
engineService.setCamera3DClipPlanes(0.1, 1000);
syncOrbitCameraToEngine(orbitCamera);
// Set a different background color for 3D mode
// 为 3D 模式设置不同的背景色
engineService.setClearColor(0.15, 0.15, 0.2, 1.0);
} else {
// Restore 2D mode background color
// 恢复 2D 模式背景色
engineService.setClearColor(0.1, 0.1, 0.12, 1.0);
}
}
}, [renderMode, engine.state.initialized, syncOrbitCameraToEngine, orbitCamera]);
// Toggle render mode handler
// 切换渲染模式处理函数
const handleToggleRenderMode = useCallback(() => {
setRenderMode((prev) => prev === '2D' ? '3D' : '2D');
}, []);
// Sync grid and gizmo visibility
// Engine will use 2D or 3D grid/gizmo based on render mode
// 引擎会根据渲染模式使用 2D 或 3D 网格/gizmo
useEffect(() => {
if (engine.state.initialized) {
EngineService.getInstance().setShowGrid(showGrid);
EngineService.getInstance().setShowGizmos(showGizmos);
// Gizmos are still 2D only for now
// Gizmo 目前仍然只有 2D 版本
const is2D = renderMode === '2D';
EngineService.getInstance().setShowGizmos(is2D && showGizmos);
}
}, [showGrid, showGizmos, engine.state.initialized]);
}, [showGrid, showGizmos, renderMode, engine.state.initialized]);
// Sync transform mode to engine
useEffect(() => {
@@ -1030,8 +1222,24 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
const handleReset = () => {
// Reset camera to origin without stopping playback
setCamera2DOffset({ x: 0, y: 0 });
setCamera2DZoom(1);
// 重置相机到原点,不停止播放
if (renderMode === '3D') {
// Reset 3D orbit camera | 重置 3D 轨道相机
const defaultOrbit = {
distance: 10,
pitch: -30,
yaw: 45,
targetX: 0,
targetY: 0,
targetZ: 0
};
setOrbitCamera(defaultOrbit);
syncOrbitCameraToEngine(defaultOrbit);
} else {
// Reset 2D camera | 重置 2D 相机
setCamera2DOffset({ x: 0, y: 0 });
setCamera2DZoom(1);
}
};
// Store handlers in refs to avoid dependency issues
@@ -1957,10 +2165,26 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
<div className="viewport-divider" />
{/* 2D/3D Mode Toggle | 2D/3D 模式切换 */}
<button
className={`viewport-btn ${renderMode === '3D' ? 'active' : ''}`}
onClick={handleToggleRenderMode}
title={renderMode === '2D' ? t('viewport.view.switchTo3D') || 'Switch to 3D' : t('viewport.view.switchTo2D') || 'Switch to 2D'}
>
{renderMode === '2D' ? <Square size={14} /> : <Box size={14} />}
<span style={{ marginLeft: 4, fontSize: 10 }}>{renderMode}</span>
</button>
<div className="viewport-divider" />
{/* Zoom display */}
<div className="viewport-zoom-display">
<ZoomIn size={12} />
<span>{Math.round(camera2DZoom * 100)}%</span>
{renderMode === '2D' ? (
<span>{Math.round(camera2DZoom * 100)}%</span>
) : (
<span>{orbitCamera.distance.toFixed(1)}m</span>
)}
</div>
<div className="viewport-divider" />

View File

@@ -1376,6 +1376,17 @@ export class EngineService {
return this._runtime?.getTransformMode() ?? 'select';
}
/**
* Render a 3D gizmo at the specified world position.
* 在指定的世界位置渲染 3D Gizmo。
*
* Only works in 3D render mode.
* 仅在 3D 渲染模式下有效。
*/
render3DGizmo(x: number, y: number, z: number, scale: number = 1.0): void {
this._runtime?.bridge?.render3DGizmo(x, y, z, scale);
}
// ===== Multi-viewport API =====
/**
@@ -1449,6 +1460,122 @@ export class EngineService {
return this._runtime;
}
// ===== 3D Camera API =====
/**
* Get render mode (0 = 2D, 1 = 3D).
* 获取渲染模式0 = 2D, 1 = 3D
*/
getRenderMode(): number {
return this._runtime?.bridge?.getRenderMode() ?? 0;
}
/**
* Set render mode (0 = 2D, 1 = 3D).
* 设置渲染模式0 = 2D, 1 = 3D
*/
setRenderMode(mode: number): void {
this._runtime?.bridge?.setRenderMode(mode);
}
/**
* Check if 3D renderer is available.
* 检查 3D 渲染器是否可用。
*/
has3DRenderer(): boolean {
return this._runtime?.bridge?.has3DRenderer() ?? false;
}
/**
* Get 3D camera position.
* 获取 3D 相机位置。
*/
getCamera3DPosition(): { x: number; y: number; z: number } | null {
return this._runtime?.bridge?.getCamera3DPosition() ?? null;
}
/**
* Set 3D camera position.
* 设置 3D 相机位置。
*/
setCamera3DPosition(x: number, y: number, z: number): void {
this._runtime?.bridge?.setCamera3DPosition(x, y, z);
}
/**
* Get 3D camera rotation (Euler angles in degrees).
* 获取 3D 相机旋转(欧拉角,度数)。
*/
getCamera3DRotation(): { pitch: number; yaw: number; roll: number } | null {
return this._runtime?.bridge?.getCamera3DRotation() ?? null;
}
/**
* Set 3D camera rotation (Euler angles in degrees).
* 设置 3D 相机旋转(欧拉角,度数)。
*/
setCamera3DRotation(pitch: number, yaw: number, roll: number): void {
this._runtime?.bridge?.setCamera3DRotation(pitch, yaw, roll);
}
/**
* Get 3D camera field of view.
* 获取 3D 相机视场角。
*/
getCamera3DFov(): number | null {
return this._runtime?.bridge?.getCamera3DFov() ?? null;
}
/**
* Set 3D camera field of view.
* 设置 3D 相机视场角。
*/
setCamera3DFov(fov: number): void {
this._runtime?.bridge?.setCamera3DFov(fov);
}
/**
* Set 3D camera projection type.
* 设置 3D 相机投影类型。
* @param type 0 = Perspective, 1 = Orthographic
* @param orthoSize Orthographic size (default 5.0)
*/
setCamera3DProjection(type: number, orthoSize: number = 5.0): void {
this._runtime?.bridge?.setCamera3DProjection(type, orthoSize);
}
/**
* Make 3D camera look at a target point.
* 让 3D 相机看向目标点。
*/
camera3DLookAt(targetX: number, targetY: number, targetZ: number): void {
this._runtime?.bridge?.camera3DLookAt(targetX, targetY, targetZ);
}
/**
* Set 3D camera clip planes.
* 设置 3D 相机裁剪平面。
*/
setCamera3DClipPlanes(near: number, far: number): void {
this._runtime?.bridge?.setCamera3DClipPlanes(near, far);
}
/**
* Resize 3D renderer.
* 调整 3D 渲染器尺寸。
*/
resize3D(width: number, height: number): void {
this._runtime?.bridge?.resize3D(width, height);
}
/**
* Render 3D frame (for editor manual rendering).
* 渲染 3D 帧(用于编辑器手动渲染)。
*/
render3D(): void {
this._runtime?.bridge?.render3D();
}
/**
* Dispose engine resources.
*/