ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

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

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

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;          // 模型URLdescription?: 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) returnaddModel()// 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 = truechild.receiveShadow = true// 启用透明效果child.material.transparent = truechild.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} /><directionalLightposition={[100, 200, 100]}intensity={3}castShadowcolor="#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.visiblehelper.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) => (<divkey={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"}
}

返回列表