本文介绍了基于React和Three.js的3D模型视图控制实现方案。通过react-three/fiber框架,系统实现了视图切换(正视图、侧视图、俯视图、等轴视图)、边界显示控制和坐标轴显示控制等功能。关键技术点包括:1) 使用React Context管理视图状态;2) 通过ViewProvider预设不同视角的相机位置参数;3) 利用Box3Helper创建模型边界盒;4) 使用AxesHelper实现坐标轴可视化。系统采用组件化设计,将视图控制面板与模型渲染分离,通过自定义hook实现数据共享。该方案提供了直观的3D模型交互体验,适用于各类3D可视化应用场景。
视图控制对象管理
由于控制视图的面板和canvas里加载模型的是两个独立的功能,如何进行数据通信呢?这里使用react的createContext搭配Provider进行数据注入和数据读取。
定义useView
通过createContext管理全局对象,
import { createContext, useContext } from 'react';
import * as THREE from 'three';
// 定义视图类型
export type ViewType = 'front' | 'top' | 'side' | 'axis' | 'free';
// 创建 Context
type ViewContextType = {
currentView: ViewType;
setCurrentView: (view: ViewType) => void;
cameraPosition: THREE.Vector3 | null;
cameraTarget: THREE.Vector3 | null;
boundaryStatus: boolean;
setBoundaryStatus: (status: boolean) => void;
showAxis: boolean;
setShowAxis: (status: boolean) => void;
};
export const ViewContext = createContext<ViewContextType | undefined>(undefined);
// 自定义 Hook 简化使用
export const useView = () => {
const context = useContext(ViewContext);
if (context === undefined) {
throw new Error('useView must be used within a ViewProvider');
}
return context;
};
定义 ViewProvider
import { ReactNode, useState } from 'react';
import * as THREE from 'three';
import { ViewContext, type ViewType } from '.';
const viewConfigs = {
front: { position: new THREE.Vector3(0, 0,2), target: new THREE.Vector3(0, 0, 0) }, // 正视图(前)
top: { position: new THREE.Vector3(0, 2, 0), target: new THREE.Vector3(0, 0, 0) }, // 俯视图(上)
side: { position: new THREE.Vector3(2, 0,0), target: new THREE.Vector3(0, 0, 0) }, // 侧视图(右)
axis: { position: new THREE.Vector3(1, 1, 1), target: new THREE.Vector3(0, 0, 0) }, // 轴测图
free: null // 自由视角(不预设,保留当前位置)
};
export const ViewProvider = ({ children }: { children: ReactNode }) => {
const [currentView, setCurrentView] = useState<ViewType>('free');
const [boundaryStatus, setBoundaryStatus] = useState<boolean>(false);
const [showAxis, setShowAxis] = useState<boolean>(false);
// 根据当前视图返回相机参数
const cameraPosition = viewConfigs[currentView]?.position || null;
const cameraTarget = viewConfigs[currentView]?.target || null;
const value ={
currentView,
setCurrentView,
cameraPosition,
cameraTarget,
boundaryStatus,
setBoundaryStatus,
showAxis,
setShowAxis,
}
return (
<ViewContext.Provider value={value}>
{children}
</ViewContext.Provider>
);
};
修改App.tsx
组件要使用context内容必须要通过ViewProvider进行包裹。这里我们直接在App.tsx将其包裹在最外层。ModelManagerProvider是用来获取几何模型数据管理几何的,配合右侧的几何管理面板使用的,在此案例中可以忽略。
import { ModelManagerProvider } from './utils/viewHelper/viewContext'
import { Home } from './views'
import { App as AntApp } from 'antd'
import { ViewProvider } from './views/ViewContext/ViewProvider'
function App() {
return (
<ViewProvider>
<ModelManagerProvider>
<AntApp style={{ width: '100%', height: '100%' }}>
<Home />
</AntApp>
</ModelManagerProvider>
</ViewProvider>
)
}
export default App
CityModal加载模型
import { useGLTF } from '@react-three/drei'
import { useThree } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
import * as THREE from 'three'
import { useModelManager } from '../../../utils/viewHelper/viewContext'
import { useView } from '../../ViewContext'
export const CityModel = ({ url }: { url: string }) => {
const { scene } = useGLTF(url)
const modelRef = useRef<THREE.Group>(null)
const helper = useModelManager()
const { camera } = useThree()
const { cameraPosition, cameraTarget, boundaryStatus, showAxis } = useView()
console.log(cameraPosition)
const raycaster = useRef(new THREE.Raycaster())
const pointer = useRef(new THREE.Vector2())
const boxHelperRef = useRef<THREE.Box3Helper>(null)
// 坐标系辅助器引用
const axesHelperRef = useRef<THREE.AxesHelper>(null)
// 存储所有创建的边缘线对象
const edgeLines = useRef<Map<string, THREE.LineSegments>>(new Map())
useEffect(() => {
if (cameraPosition && cameraTarget) {
camera.position.copy(cameraPosition)
camera.lookAt(cameraTarget)
}
}, [cameraPosition, cameraTarget])
useEffect(() => {
if (boxHelperRef.current) {
if (boundaryStatus) {
scene.add(boxHelperRef.current)
} else {
scene.remove(boxHelperRef.current)
}
}
}, [boundaryStatus])
// 控制坐标系显示/隐藏
useEffect(() => {
if (axesHelperRef.current) {
if (showAxis) {
scene.add(axesHelperRef.current) // 添加到场景
} else {
scene.remove(axesHelperRef.current)
}
}
}, [showAxis])
// 绑定点击事件
useEffect(() => {
window.addEventListener('click', handleClick)
return () => window.removeEventListener('click', handleClick)
}, [])
// 模型加载后初始化
useEffect(() => {
if (!modelRef.current) return
addModel()
const box = new THREE.Box3().setFromObject(modelRef.current)
const center = new THREE.Vector3()
box.getCenter(center)
const size = new THREE.Vector3()
box.getSize(size)
// 2. 将模型中心移到世界原点(居中)
modelRef.current.position.sub(
new THREE.Vector3(center.x, center.y, center.z),
) // 反向移动模型,使其中心对齐原点
boxHelperRef.current = new THREE.Box3Helper(box, 0x00ff00) // 绿色边框
// 遍历模型设置通用属性并标记可交互
addMaterial()
const axisLength = Math.max(size.x, size.y, size.z)
const axisPosition = new THREE.Vector3(
center.x,
0, // Y 轴起点:地面(Y=0)
center.z, // Z 轴起点
)
if (!axesHelperRef.current) {
axesHelperRef.current = new THREE.AxesHelper(axisLength * 2) // 参数为轴长
axesHelperRef.current.position.copy(axisPosition) // 放置在模型左下角附近
}
}, [])
//添加材质
const addMaterial = () => {
if (!modelRef.current) return
// 遍历模型设置通用属性并标记可交互
modelRef.current.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.castShadow = true
child.receiveShadow = true
child.material.transparent = true
// 标记为可交互(后续可通过此属性过滤)
child.userData.interactive = true
if (!child.name.includes('River')) {
child.material.color.setStyle('#0a1a3a')
}
addHighlight(child)
// 保存原始材质(用于后续恢复或高亮逻辑)
if (!child.userData.baseMaterial) {
child.userData.baseMaterial = child.material // 存储原始材质
}
}
})
}
// 添加边缘高亮效果
const addHighlight = (object: THREE.Mesh) => {
if (!object.geometry) return
// 创建边缘几何体
const geometry = new THREE.EdgesGeometry(object.geometry)
// 创建边缘线材质
const material = new THREE.LineBasicMaterial({
color: 0x4c8bf5, // 蓝色边缘
linewidth: 2, // 线宽
})
// 创建边缘线对象
const line = new THREE.LineSegments(geometry, material)
line.name = 'surroundLine'
// 复制原始网格的变换
line.position.copy(object.position)
line.rotation.copy(object.rotation)
line.scale.copy(object.scale)
// 设置为模型的子对象,确保跟随模型变换
object.add(line)
edgeLines.current.set(object.uuid, line)
}
// 处理点击事件
const handleClick = (event: MouseEvent) => {
if (event.button !== 0) return
// 计算点击位置的标准化设备坐标
pointer.current.x = (event.clientX / window.innerWidth) * 2 - 1
pointer.current.y = -(event.clientY / window.innerHeight) * 2 + 1
// 执行射线检测
raycaster.current.setFromCamera(pointer.current, camera)
const intersects = raycaster.current.intersectObject(
modelRef.current!,
true,
)
console.log(intersects)
}
// 添加模型到管理器
const addModel = () => {
if (modelRef.current) {
helper.addModel({
id: '模型1',
name: '模型1',
url: url,
model: modelRef.current,
})
}
}
return (
<>
<primitive object={scene} ref={modelRef} />
</>
)
}
ViewOperation视图控制组件
import { Button, Flex, Image, Tooltip } from 'antd'
import {
BorderOutlined,
DragOutlined,
} from '@ant-design/icons'
import { useView } from '../ViewContext'
export const ViewOperation = () => {
const {
setCurrentView,
boundaryStatus,
setBoundaryStatus,
showAxis,
setShowAxis,
} = useView()
return (
<div
className="absolute top-[10px] left-[50%] text-white z-10"
style={{ transform: 'translateX(-50%)' }}
>
<Flex gap={10} align="center">
<Tooltip title={showAxis ? '隐藏坐标系' : '显示坐标系'}>
<Button
type="text"
onClick={() => {
setShowAxis(!showAxis)
}}
>
<DragOutlined className="text-white" />
</Button>
</Tooltip>
<Tooltip title={boundaryStatus ? '隐藏边界' : '显示边界'}>
<Button
type="text"
onClick={() => {
setBoundaryStatus(!boundaryStatus)
}}
>
<BorderOutlined className="text-white" />
</Button>
</Tooltip>
<Tooltip title={'正视图'}>
<Button
type="text"
onClick={(e) => {
e.stopPropagation()
setCurrentView('front')
}}
>
<Image src="/images/zhengshitu.png" width={20} preview={false} />
</Button>
</Tooltip>
<Tooltip title="俯视图">
<Button
type="text"
onClick={(e) => {
e.stopPropagation()
setCurrentView('top')
}}
>
<Image src="/images/fushitu.png" width={20} preview={false} />
</Button>
</Tooltip>
<Tooltip title="侧视图">
<Button
type="text"
onClick={(e) => {
e.stopPropagation()
setCurrentView('side')
}}
>
<Image src="/images/ceshitu.png" width={20} preview={false} />
</Button>
</Tooltip>
<Tooltip title="轴视图">
<Button
type="text"
onClick={(e) => {
e.stopPropagation()
setCurrentView('axis')
}}
>
<Image src="/images/zhoushitu.png" width={20} preview={false} />
</Button>
</Tooltip>
</Flex>
</div>
)
}
核心功能讲解
视图切换
在ViewProvider里定义好各种视图对应的camera的位置
const viewConfigs = {
front: { position: new THREE.Vector3(0, 0,2), target: new THREE.Vector3(0, 0, 0) }, // 正视图(前)
top: { position: new THREE.Vector3(0, 2, 0), target: new THREE.Vector3(0, 0, 0) }, // 俯视图(上)
side: { position: new THREE.Vector3(2, 0,0), target: new THREE.Vector3(0, 0, 0) }, // 侧视图(右)
axis: { position: new THREE.Vector3(1, 1, 1), target: new THREE.Vector3(0, 0, 0) }, // 轴测图
free: null // 自由视角(不预设,保留当前位置)
};
在视图控制组件中切换不同的视角
const {
setCurrentView,
} = useView()
<Tooltip title={'正视图'}>
<Button
type="text"
onClick={(e) => {
e.stopPropagation()
setCurrentView('front')
}}
>
<Image src="/images/zhengshitu.png" width={20} preview={false} />
</Button>
</Tooltip>
<Tooltip title="俯视图">
<Button
type="text"
onClick={(e) => {
e.stopPropagation()
setCurrentView('top')
}}
>
<Image src="/images/fushitu.png" width={20} preview={false} />
</Button>
</Tooltip>
<Tooltip title="侧视图">
<Button
type="text"
onClick={(e) => {
e.stopPropagation()
setCurrentView('side')
}}
>
<Image src="/images/ceshitu.png" width={20} preview={false} />
</Button>
</Tooltip>
<Tooltip title="轴视图">
<Button
type="text"
onClick={(e) => {
e.stopPropagation()
setCurrentView('axis')
}}
>
<Image src="/images/zhoushitu.png" width={20} preview={false} />
</Button>
</Tooltip>
之后再模型加载组件中根据相机位置信息及时更新相机
获取相机camera对象
const { camera } = useThree()
获取相机位置、相机朝向
const { cameraPosition, cameraTarget } = useView()
通过useEffect监听cameraPosition位置变化,改变相机位置
useEffect(() => {
if (cameraPosition && cameraTarget) {
camera.position.copy(cameraPosition)
camera.lookAt(cameraTarget)
}
}, [cameraPosition, cameraTarget])
模型边框显示
模型边框显示是利用Box3Helper创建了一个box边框对象,根据是否显隐控制scene是add还是remove掉。
防止react渲染canvas丢失数据,通常对3D对象定义的是useRef对象
const boxHelperRef = useRef<THREE.Box3Helper>(null)
根据boundaryStatus控制scene是否将边框对象添加入场景中
const { scene } = useGLTF(url)
const { boundaryStatus} = useView()
useEffect(() => {
if (boxHelperRef.current) {
if (boundaryStatus) {
scene.add(boxHelperRef.current)
} else {
scene.remove(boxHelperRef.current)
}
}
}, [boundaryStatus])
已加载的模型都可以通过new THREE.Box3()的setFromObject(模型)获得的box对象,然后借助Box3Helper轻松生成一个外边框模型,但是没有添加到scene中,再上面的useEffect里当boundaryStatus为true时将其添加入场景就可以显示了
const box = new THREE.Box3().setFromObject(modelRef.current)
boxHelperRef.current = new THREE.Box3Helper(box, 0x00ff00) // 绿色边框 仅创建并没添加到场景中,因此不会显示
return (
<>
<primitive object={scene} ref={modelRef} />
</>
)
坐标轴显示
同边框显示一样,坐标轴也是scene中的一个对象而已。
// 坐标系辅助器引用
const axesHelperRef = useRef<THREE.AxesHelper>(null)
// 控制坐标系显示/隐藏
useEffect(() => {
if (axesHelperRef.current) {
if (showAxis) {
scene.add(axesHelperRef.current) // 添加到场景
} else {
scene.remove(axesHelperRef.current)
}
}
}, [showAxis])
这里我将坐标轴设置在模型的中心点,就要计算模型的center;
并且坐标轴要设置长度,为了包裹模型,可以设置比模型最大的长度的倍数。
const box = new THREE.Box3().setFromObject(modelRef.current)
const center = new THREE.Vector3()
box.getCenter(center)
const axisLength = Math.max(size.x, size.y, size.z)
const axisPosition = new THREE.Vector3(
center.x,
0, // Y 轴起点:地面(Y=0)
center.z, // Z 轴起点
)
if (!axesHelperRef.current) {
axesHelperRef.current = new THREE.AxesHelper(axisLength * 2) // 参数为轴长
axesHelperRef.current.position.copy(axisPosition) // 放置在模型左下角附近
}