React Three Fiber 实现 3D 模型视图切换、显隐边框、显隐坐标轴

发布于:2025-07-15 ⋅ 阅读:(11) ⋅ 点赞:(0)

本文介绍了基于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) // 放置在模型左下角附近
    }


网站公告

今日签到

点亮在社区的每一天
去签到