Three.js 如何控制 GLB 模型的内置属性实现精准显示

发布于:2025-07-13 ⋅ 阅读:(14) ⋅ 点赞:(0)

threeJs入门绕不开的就是加载模型,本文以react-three/fiber库为例,演示glb模型加载,遍历模型属性,带你掌握glb模型。以此格式模型为例,带你了解threeJs如何与模型进行交互,演绎千变万化的神奇效果。本文的示例代码见文章末尾。

如何定位模型中某个几何/物体?

拿到一个模型后,比如从网上download下的3d模型,你并不知道模型是由哪些部分组成,以及如何定位具体的几何。怎么办,就要去找模型的name。threejs提供了traverse进行遍历模型,通过debugger/console.log打印到控制台可以看到模型对象具体的参数信息,本文用到name和visible属性进行找对象以及控制对象显隐

traverse方法遍历模型 

 在 Three.js 中,traverse() 方法是一个强大的工具,用于递归遍历 3D 场景图中的所有对象(包括模型、网格、材质等)。这在处理复杂的 GLB 模型时特别有用,因为 GLB 模型通常由多个嵌套的对象组成。通过加断点可以看到当前模型某个对象的name

一、基本用法

traverse() 方法接受一个回调函数,该函数会对模型中的每个对象执行一次:

model.traverse((child) => {
  // 对每个子对象执行操作
  if (child.isMesh) {
    // 如果是网格对象
    console.log('找到网格:', child.name);
  }
});

二、常见应用场景

1. 修改材质属性
model.traverse((child) => {
  if (child.isMesh) {
    // 修改所有网格的材质
    child.material.color.set(0x4a90e2);        // 设置颜色为蓝色
    child.material.metalness = 0.2;           // 降低金属感
    child.material.roughness = 0.8;           // 增加粗糙度
    child.material.transparent = true;        // 启用透明
    child.material.opacity = 0.9;             // 设置透明度
  }
});
2. 启用阴影投射和接收
model.traverse((child) => {
  if (child.isMesh) {
    child.castShadow = true;       // 允许对象投射阴影
    child.receiveShadow = true;    // 允许对象接收阴影
  }
});
3. 查找特定命名的对象
// 查找名为"Building_01"的对象
const building = model.getObjectByName('Building_01');
if (building) {
  building.scale.set(1.2, 1.2, 1.2);  // 放大该建筑
}

// 或者使用traverse查找
model.traverse((child) => {
  if (child.name === 'Building_01') {
    child.material.emissive.set(0xff0000);  // 设置为红色发光
  }
});
4. 修改几何体
model.traverse((child) => {
  if (child.isMesh && child.name.includes('Road')) {
    // 修改道路几何体
    const geometry = child.geometry;
    const positions = geometry.attributes.position.array;
    
    // 对道路顶点做一些修改(例如提升高度)
    for (let i = 0; i < positions.length; i += 3) {
      positions[i + 1] += 0.1;  // 提升Y坐标(高度)
    }
    
    geometry.attributes.position.needsUpdate = true;
    geometry.computeVertexNormals();
  }
});

5. 添加交互事件
// 为所有网格添加点击交互
model.traverse((child) => {
  if (child.isMesh) {
    child.userData.isInteractive = true;  // 标记为可交互
  }
});

// 在射线检测中使用
const intersects = raycaster.intersectObjects(model.children, true);
if (intersects.length > 0) {
  const selectedObject = intersects[0].object;
  if (selectedObject.userData.isInteractive) {
    // 处理交互逻辑
    console.log('点击了:', selectedObject.name);
  }
}

四、结合 GLB 模型的高级用法

当处理包含骨骼动画或特殊结构的 GLB 模型时:

1. 控制骨骼动画
model.traverse((child) => {
  if (child.isBone) {
    // 控制特定骨骼(如手臂)
    if (child.name === 'Arm_Right') {
      child.rotation.x = Math.sin(Date.now() * 0.001) * 0.5;  // 摆动手臂
    }
  }
});
2. 处理材质变体
model.traverse((child) => {
  if (child.isMesh && child.name === 'Car') {
    // 根据条件切换材质
    if (isNightMode) {
      child.material = nightMaterial;
    } else {
      child.material = dayMaterial;
    }
  }
});

五、调试技巧

在遍历过程中打印对象信息,帮助理解模型结构:

model.traverse((child) => {
  console.log(
    `对象: ${child.name}`,
    `类型: ${child.type}`,
    `位置: ${child.position.x.toFixed(2)}, ${child.position.y.toFixed(2)}, ${child.position.z.toFixed(2)}`,
    `可见: ${child.visible}`
  );
});

通过 traverse() 方法,你可以精确控制 GLB 模型中的每个组件,实现从简单的外观修改到复杂的交互行为的各种效果。

 示例完整代码

管理模型的类与hooks

为了让模型的加载和操作数据分开,我创建了一个类管理模型的加载。并通过事件发布订阅模式让使用组件的地方能够即时获取的新的模型数据。避免模型加载后操作模型的组件读取不到模型数据。

viewHelper.ts

import * as THREE from 'three'
import { EventEmitter } from 'events'; // Node.js 风格的事件系统(浏览器环境也可用)

// 模型基本信息
export interface ModelInfo {
  id: string;           // 模型唯一标识
  name: string;         // 模型显示名称
  url?: string;          // 模型URL
  description?: string; // 模型描述
  tags?: string[];      // 模型标签(用于分类)
  isVisible?: boolean;  // 模型是否可见
  isLoaded?: boolean;   // 模型是否已加载
  error?: string;       // 加载错误信息
  model?: THREE.Group; // 模型对象
}
// 模型管理器接口
export interface ModelManager {
  addModel(model: ModelInfo): void;
  getModel(id: string): ModelInfo | undefined;
  getAllModels(): ModelInfo[];
  updateModel(id: string, updates: Partial<ModelInfo>): boolean;
  setModelVisibility(id: string, visible: boolean): boolean;
  toggleModelVisibility(id: string): boolean;
  markModelAsLoaded(id: string): boolean;
  markModelAsFailed(id: string, error: string): boolean;
  getModelsByTag(tag: string): ModelInfo[];
  deleteModel(id: string): boolean;
  clearAllModels(): void;
  onchange(callback: () => void): () => void;
   updateModelVisibility(meshId: string, visible: boolean): void;
}

// 模型管理器实现
export class ModelManagerImpl implements ModelManager  {
  private models: Map<string, ModelInfo> = new Map();

    private eventEmitter = new EventEmitter(); // 用于事件通知

  // 添加模型
  addModel(model: ModelInfo): void {
    if (this.models.has(model.id)) {
      console.warn(`模型 ${model.id} 已存在,将覆盖`);
    }
    this.models.set(model.id, {
      ...model,
      isVisible: model.isVisible ?? true,
      isLoaded: model.isLoaded ?? false
    });
    this.eventEmitter.emit('change');
  }

  // 获取模型
  getModel(id: string): ModelInfo | undefined {
    return this.models.get(id);
  }

  // 获取所有模型
  getAllModels(): ModelInfo[] {
    return Array.from(this.models.values());
  }

  // 更新模型信息
  updateModel(id: string, updates: Partial<ModelInfo>): boolean {
    const model = this.models.get(id);
    if (!model) {
      console.warn(`模型 ${id} 不存在`);
      return false;
    }
    
    this.models.set(id, { ...model, ...updates });
    return true;
  }

  // 设置模型可见性
  setModelVisibility(id: string, visible: boolean): boolean {
    return this.updateModel(id, { isVisible: visible });
  }

  // 切换模型可见性
  toggleModelVisibility(id: string): boolean {
    const model = this.models.get(id);
    if (!model) return false;
    
    return this.updateModel(id, { isVisible: !model.isVisible });
  }

  // 标记模型为已加载
  markModelAsLoaded(id: string): boolean {
    return this.updateModel(id, { isLoaded: true, error: undefined });
  }

  // 标记模型加载失败
  markModelAsFailed(id: string, error: string): boolean {
    return this.updateModel(id, { isLoaded: false, error });
  }

  // 按标签筛选模型
  getModelsByTag(tag: string): ModelInfo[] {
    return Array.from(this.models.values()).filter(
      model => model.tags?.includes(tag)
    );
  }

  // 删除模型
  deleteModel(id: string): boolean {
    const result = this.models.delete(id);
    if (result) this.eventEmitter.emit('change'); // 触发事件
    return result;
  }

  // 清空所有模型
  clearAllModels(): void {

    this.models.clear();
  }

    // 注册事件监听器
  onchange(callback: () => void): () => void {
    this.eventEmitter.on('change', callback);
    return () => this.eventEmitter.off('change', callback); // 返回取消订阅函数
  }
    updateModelVisibility(meshId: string, visible: boolean): void {
    // 遍历所有模型,找到对应 mesh 并更新其可见性
    this.models.forEach((model) => {
      model.model?.traverse((child) => {
        if (child instanceof THREE.Mesh && child.uuid === meshId) {
          child.visible = visible;
        }
      });
    });
    
    // 触发模型变更事件,更新所有监听者
    this.eventEmitter.emit('change');
  }
}

 viewContext.tsx

import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { ModelManagerImpl, type ModelInfo, type ModelManager } from './ViewHelper';
/* eslint-disable react-refresh/only-export-components */
// 创建上下文
const ModelManagerContext = createContext<ModelManager | null>(null);

// 提供者组件
export const ModelManagerProvider = ({ children }: React.PropsWithChildren<{}>) => {

const modelManager = useMemo(() => {
  const manager = new ModelManagerImpl();
  // 每次创建新实例时,强制更新全局变量
  (window as any).___MODELMANAGER___ = manager; 
  console.log('新实例已创建并挂载到全局变量'); // 用于追踪实例创建
  return manager;
}, []);
  return (
    <ModelManagerContext.Provider value={modelManager}>
      {children}
    </ModelManagerContext.Provider>
  );
};
// 自定义Hook
export const useModelManager = (): ModelManager => {
  const manager = useContext(ModelManagerContext);
  if (!manager) {
    throw new Error('useModelManager must be used within a ModelManagerProvider');
  }
  return manager;
};
export const useModels = () => {
  const manager = useModelManager();
  const [models, setModels] = useState<ModelInfo[]>(manager.getAllModels());

  useEffect(() => {
    // 初始加载
    setModels(manager.getAllModels());
    
    // 订阅模型变化事件
    const unsubscribe = manager.onchange(() => {
      setModels(manager.getAllModels());
    });
    
    // 组件卸载时取消订阅
    return () => unsubscribe();
  }, [manager]);

  return models;
};

模型加载后可以通过控制台打印console.log('___MODELMANAGER___')看到模型数据

console.log(___MODELMANAGER___)

组件代码 

加载模型

模型是我在网上找的开源模型

Newsfeed - Sketchfab

在这个网站上搜索shanghai关键字就有了

 CityModal.tsx

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'

export const CityModel = ({ url }: { url: string }) => {
  // 加载GLB模型
  const { scene } = useGLTF(url)

  const modelRef = useRef<THREE.Group>(null)

  const helper = useModelManager()

  // 4. 调整相机位置(确保能完整看到模型)
    const { camera, scene: threeScene } = useThree() 

  // 新增:坐标轴辅助工具引用
  // const axesRef = useRef<THREE.AxesHelper>(null)

  // 模型加载后处理
  useEffect(() => {
    if (!modelRef.current) return
    addModel()
    // 1. 计算模型包围盒(获取模型的尺寸和中心)
    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(center) // 反向移动模型,使其中心对齐原点

    // 3. 计算合适的相机距离(基于模型最大尺寸)
    const maxDim = Math.max(size.x, size.y, size.z) // 模型最大维度
    const fov = 100 // 相机视场角(需与Canvas中camera.fov一致)
    const cameraZ = Math.abs(maxDim / 2 / Math.tan((Math.PI * fov) / 360)) // 计算合适的相机距离

    camera.position.set(0, maxDim * 0.8, cameraZ * 1.2) // 相机位置:上方+远处
    camera.lookAt(0, 0, 0) // 相机对准原点(模型中心)

    // 遍历模型设置通用属性
    modelRef.current.traverse((child) => {
      if (child instanceof THREE.Mesh) {
        // 启用阴影
        child.castShadow = true
        child.receiveShadow = true

        // 启用透明效果
        child.material.transparent = true
        child.material.opacity = 0.8
        // 优化材质(根据模型情况调整)
        if (child.material instanceof THREE.MeshStandardMaterial) {
          child.material.shadowSide = THREE.DoubleSide
        }
      }
    })

    // 新增:创建并添加坐标轴
    // createAxesHelper(size)
  }, [modelRef.current])

//  const createAxesHelper = (modelSize: THREE.Vector3) => {
//     // 移除旧坐标轴(从世界场景中移除)
//     if (axesRef.current) {
//       threeScene.remove(axesRef.current)
//     }
    
//     // 计算坐标轴大小(模型最大维度的50%,确保可见)
//     const maxDim = Math.max(modelSize.x, modelSize.y, modelSize.z)
//     const axesSize = maxDim * 1
    
//     // 创建新坐标轴
//     axesRef.current = new THREE.AxesHelper(axesSize)
    
//     // 坐标轴位置直接设为世界原点(0,0,0),不受模型组位置影响
//     axesRef.current.position.set(0, 0, 0)
    
//     // 将坐标轴添加到Three.js场景(世界坐标系)
//     threeScene.add(axesRef.current)
//   }


  const addModel = () => {
    if (modelRef.current) {
      // 创建模型数据管理器
      helper.addModel({
        id: '模型1',
        name: '模型1',
        url: '模型1',
        model: modelRef.current,
      })
    }
  }
  return (
    <>
      <primitive object={scene} ref={modelRef} />
    </>
  )
}

 CityScene.tsx

import { Suspense, useRef } from 'react'
import { CityModel } from '../model/CityModal'
import { OrbitControls } from '@react-three/drei'

export const CityScene = ({ modelUrl }: { modelUrl: string }) => {
    const controlsRef = useRef<any>(null)

  return (
    <>
      <Suspense>
        <CityModel url={modelUrl} />
        {/* 控制器 */}
   <OrbitControls ref={controlsRef} />
      </Suspense>
      {/* 环境光和方向光 */}
      <ambientLight intensity={0.5} color={0xffffff} />
      <directionalLight
        position={[100, 200, 100]}
        intensity={3}
        castShadow
        color="#ffffff"
        shadow-mapSize-width={2048}
        shadow-mapSize-height={2048}
      />

      {/* 环境贴图 */}

      {/* 纯色背景替代环境贴图 */}
      <color attach="background" args={['#0a1a3a']} />
    </>
  )
}

CityView组件

import { Canvas } from '@react-three/fiber'
import { CityScene } from './scene/CityScene'

export const CityView = () => {
  const cityModelUrl = '/models/city-_shanghai-sandboxie.glb'
  return (
    <div className="w-[100vw] h-full absolute">
      <Canvas style={{ width: '100vw', height: '100vh' }}  shadows={true}   >
        <ambientLight />
        <CityScene modelUrl={cityModelUrl} />
      </Canvas>
    </div>
  )
}

操作面板组件OperationPanel.tsx

import { Space, Tag } from 'antd'
import { useModelManager, useModels } from '../../utils/viewHelper/viewContext'
import './index.less'
import * as THREE from 'three'

export const OperationPanel = () => {
  const helper = useModelManager()
  const models = useModels()

  // 收集模型子对象
  const getMeshChildren = (model: THREE.Group | undefined): THREE.Mesh[] => {
    const meshes: THREE.Mesh[] = []
    model?.traverse((child) => {
      // console.log(child.type)
      //   if (child instanceof THREE.Mesh) {
      meshes.push(child)
      //   }
    })
    return meshes
  }

  // 切换可见性
  const toggleMeshVisibility = (mesh: THREE.Mesh) => {
    mesh.visible = !mesh.visible
    helper.updateModelVisibility(mesh.uuid, mesh.visible) // 可选:通知管理器保存状态
  }

  return (
    <div className="screen-operation absolute top-[10px] right-[30px] w-[20vw] h-[60vh] text-white z-10 ">
      {/* 面板标题栏 */}
      <div className=" px-[12px] py-3 shadow-lg">
        <span className="text-lg font-semibold tracking-wide">操作面板</span>
      </div>

      {/* 模型列表 */}
      <div className="overflow-y-auto h-[calc(100%-30px)]">
        {models.map((model) => {
          const meshes = getMeshChildren(model.model)

          return (
            <div key={model.id} className="mt-[10px]">
              {/* 子对象列表 */}
              <div className="space-y-1 pl-[3px] pb-[6px]">
                {meshes.length > 0 ? (
                  meshes.map((mesh) => (
                    <div
                      key={mesh.uuid}
                      // 核心样式:状态颜色 + 悬浮效果
                      className={`
                        px-[6px] my-[6px] rounded-md cursor-pointer transition-all duration-300 ease-out
                       
                        ${
                          /* 基础样式 */ 'shadow-md transform border border-transparent'
                        }
                        ${
                          /* 悬浮效果 */ 'hover:translate-x-1 hover:shadow-lg hover:shadow-blue-900/20'
                        }
                        ${
                          /* 显示状态 */ mesh.visible
                            ? 'bg-gradient-to-r from-[#47718b] to-[#3a5a70] text-[#fff] hover:border-[#8ec5fc]'
                            : 'bg-gradient-to-r from-[#2d3b45] to-[#1f2930] text-[#a0a0a0] hover:border-[#555]'
                        }
                      `}
                      title={mesh.name}
                      onClick={() => toggleMeshVisibility(mesh)}
                    >
                      <div className="flex items-center justify-between">
                        <span className="font-medium w-[14vw] overflow-hidden whitespace-nowrap text-ellipsis">
                          {mesh.name}
                        </span>
                        <Space >
                          <Tag>{mesh.type}</Tag>
                          <Tag color={mesh.visible ? 'success' : 'error'}> {mesh.visible ? '显示中' : '已隐藏'}</Tag>
                        </Space>
                      </div>
                    </div>
                  ))
                ) : (
                  <div className="px-3 py-4 text-center text-gray-400 italic">
                    无可用模型数据
                  </div>
                )}
              </div>
            </div>
          )
        })}
      </div>
    </div>
  )
}

Home组件

import { CityView } from './CityView'
import './index.less'
import { OperationPanel } from './OperationPanel'
export const Home = () => {
  return (
    <div className="screen-container">

         <CityView />
         <OperationPanel />
    </div>
  )
}

package.json

如果你有缺失的依赖,可以参考我的进行补充

{
  "name": "digital-twin-city",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "lint": "eslint .",
    "preview": "vite preview"
  },
  "dependencies": {
    "@react-three/cannon": "^6.6.0",
    "@react-three/drei": "^10.5.0",
    "@react-three/fiber": "^9.2.0",
    "@tailwindcss/postcss": "^4.1.11",
    "@tailwindcss/vite": "^4.1.11",
    "antd": "^5.26.4",
    "autoprefixer": "^10.4.21",
    "events": "^3.3.0",
    "postcss": "^8.5.6",
    "postcss-preset-env": "^10.2.4",
    "react": "^19.1.0",
    "react-dom": "^19.1.0",
    "tailwindcss": "^4.1.11",
    "three": "^0.178.0",
    "zustand": "^5.0.6"
  },
  "devDependencies": {
    "@eslint/js": "^9.30.1",
    "@types/react": "^19.1.8",
    "@types/react-dom": "^19.1.6",
    "@types/three": "^0.178.0",
    "@vitejs/plugin-react": "^4.6.0",
    "eslint": "^9.30.1",
    "eslint-plugin-react-hooks": "^5.2.0",
    "eslint-plugin-react-refresh": "^0.4.20",
    "globals": "^16.3.0",
    "less": "^4.3.0",
    "typescript": "~5.8.3",
    "typescript-eslint": "^8.35.1",
    "vite": "^6.2.0"
  }
}


网站公告

今日签到

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