本文介紹了一個基于React和Three.js的3D壓力可視化解決方案,該方案能夠:
加載并渲染3D壓力模型數據
提供動態顏色映射功能,支持多種顏色方案:彩虹-rainbow,冷暖-cooltowarm,黑體-blackbody,灰度-grayscale
實現固定位置的顏色圖例顯示
通過GUI界面實現交互式控制
這個解決方案特別適用于工程仿真、科學計算可視化、醫療成像等需要展示3D壓力/溫度/密度分布的場景。
本文參考threejs官網 的例子進行了react重構
three.js examples
幾何模型數據分析
首先,下載threejs的源碼,找到該例子中需要的模型文件
?分析模型數據,可以看到模型是BufferGeometry類型的,有兩個屬性position位置信息,pressure壓力信息
?
?展開position可以看到具體位置屬性的參數,itemSize=3表示,每三個表示一個頂點的xyz坐標。
?
展開pressure可以看到itemSize=1,每一個數值對應一個頂點的壓力值
?我們現在要做的兩件事,首先,根據這個位置信息將幾何繪制出來,通過THREE.BufferGeometryLoader()完成;其次,根據每個頂點的壓力值,繪制該頂點的顏色。
第一個很容易。
加載幾何
export const Model3D = () => {const meshRef = useRef<THREE.Mesh>(null)const [geometry, setGeometry] = useState<PressureGeometry | null>(null)useEffect(() => {const loader = new THREE.BufferGeometryLoader()loader.load('models/json/pressure.json', (geometry) => {const geometry2 = geometry as unknown as PressureGeometrygeometry.center(); // 確保模型居中geometry.computeVertexNormals(); // 計算法線以正確顯示光照const colors = []for (let i = 0, n = geometry.attributes.position.count; i < n; ++i) {colors.push(1, 1, 1)}geometry.setAttribute('color',new THREE.Float32BufferAttribute(colors, 3),)setGeometry(geometry2)})}, [])if (!geometry) return nullreturn (<mesh ref={meshRef} geometry={geometry}><meshLambertMaterialside={THREE.DoubleSide}color={0xf5f5f5}vertexColors={true}/></mesh>)
}
1.?geometry.center()
:幾何體居中
將幾何體的頂點坐標重新計算,使其中心點移動到坐標系原點 (0, 0, 0)。
默認情況下,加載的 3D 模型可能不在場景中心,導致旋轉/縮放時出現偏移問題。
底層原理
-
計算幾何體的包圍盒(BoundingBox),獲取?
min
?和?max
?坐標。 -
計算幾何體的中心點:
const centerX = (min.x + max.x) / 2; const centerY = (min.y + max.y) / 2; const centerZ = (min.z + max.z) / 2;
-
將所有頂點坐標減去中心點值,使模型中心對齊到?
(0, 0, 0)
。
適用場景
加載外部 3D 模型(如?
.json
、.gltf
)時,確保模型位于場景中心。避免因模型偏移導致的相機對焦問題或交互異常。
2.?geometry.computeVertexNormals()
:計算頂點法線
自動計算每個頂點的法線向量(用于光照計算)。
如果幾何體沒有法線數據,或修改了頂點位置后未更新法線,模型的光照會顯示不正確(如全黑或閃爍)。
底層原理
遍歷所有三角形面(
faces
?或?index
?緩沖數據)。對每個三角形,計算其面法線(垂直于三角形平面的向量)。
對共享同一頂點的所有面法線取加權平均,得到該頂點的平滑法線。
為什么需要?
Three.js 的光照(如?
MeshLambertMaterial
、MeshPhongMaterial
)依賴法線數據。如果未提供法線,模型會失去立體感(如下圖對比):
? 有法線?→ 平滑光照
? 無法線?→ 平坦或異常著色
思考:如何繪制頂點顏色?
如何繪制頂點顏色呢?
如果我們想繪制一個彩虹色[紅,黃,**,綠,藍],并且指定一個數值區間[min,max],然后將顏色在指定區間內進行均勻采樣,那么對于給定的數值,我們就能得到對應的顏色值。這個如何實現呢?
顏色映射?
在 3D 科學可視化(如壓力、溫度、密度分布)中,顏色映射(Color Mapping)?是關鍵步驟,它通過將數值數據映射到顏色,直觀地表達數據的分布和變化趨勢。
1. 數值到顏色的映射原理
(1)數據歸一化(Normalization)
由于數值范圍可能很大(如壓力 0~2000Pa),而顏色查找表(LUT)通常使用 **0~1 范圍**,因此需要先歸一化:
normalizedValue =(value-minValue)/(maxValue - minValue)
例如:
壓力范圍?
[0, 2000]
,當前值?800
歸一化后:
(800 - 0) / (2000 - 0) = 0.4
(2)顏色查找(Color Lookup)
歸一化后的值(0~1)通過?顏色查找表(LUT)?找到對應的 RGB 顏色:
LUT 存儲了一系列顏色漸變(如?
rainbow
、cooltowarm
、grayscale
)。例如,
0.4
?可能對應?rgb(100, 200, 50)
。
2. Three.js 的?Lut
?類
Three.js 提供了?
Lut
(Lookup Table)?工具類(位于?three/examples/jsm/math/Lut.js
),用于數值到顏色的映射。
(1)基本用法
import { Lut } from 'three/examples/jsm/math/Lut.js';// 初始化 LUT
const lut = new Lut();// 設置顏色映射方案(內置多種預設)
lut.setColorMap("rainbow"); // "cooltowarm", "blackbody", "grayscale"...// 設置數值范圍
lut.setMin(0); // 最小值
lut.setMax(2000); // 最大值// 獲取顏色
const color = lut.getColor(800); // 返回 THREE.Color 對象
console.log(color.r, color.g, color.b); // 例如: (0.2, 0.8, 0.5)
(2)內置顏色映射方案
名稱 | 效果 | 適用場景 |
---|---|---|
rainbow | 彩虹色(紅→紫) | 通用科學可視化 |
cooltowarm | 冷色(藍)→暖色(紅) | 溫度場、正負值 |
blackbody | 黑體輻射(黑→紅→黃→白) | 高溫場、熱力學 |
grayscale | 灰度(黑→白) | 簡單數據對比 |
(3)自定義顏色映射
可以手動定義漸變顏色:
lut.setColorMap("custom", [new THREE.Color(0x0000ff), // 藍(最小值)new THREE.Color(0x00ff00), // 綠(中間值)new THREE.Color(0xff0000), // 紅(最大值)
]);
3. 在 React + Three.js 中的實際應用
如本文將使用Lut
?用于動態更新 3D 模型的頂點顏色:
useEffect(() => {if (!geometry) return;const lut = lutRef.current;lut.setColorMap(colorMap); // 設置顏色方案(如 "rainbow")lut.setMax(2000); // 最大值lut.setMin(0); // 最小值const pressures = geometry.attributes.pressure.array;const colors = geometry.attributes.color;for (let i = 0; i < pressures.length; i++) {const value = pressures[i];const color = lut.getColor(value); // 獲取顏色colors.setXYZ(i, color.r, color.g, color.b); // 設置頂點顏色}colors.needsUpdate = true; // 通知 Three.js 更新顏色
}, [colorMap, geometry]);
關鍵點
lut.getColor(value)
?自動歸一化并返回?THREE.Color
。
colors.setXYZ()
?修改頂點顏色屬性。
needsUpdate = true
?確保 GPU 緩沖區更新。
4. 顏色映射的應用場景
場景 | 數據類型 | 典型顏色方案 |
---|---|---|
工程仿真 | 結構應力、流體壓力 | cooltowarm (區分高低壓) |
科學計算 | 溫度場、密度場 | blackbody (高溫可視化) |
醫療成像 | CT/MRI 數據 | grayscale (傳統醫學影像) |
?繪制固定色卡ColorMap
在我們場景中ColorMap不會跟著模型一起移動,而是固定在頁面的左側,這是如何做到的呢?
?這里使用的是獨立渲染Scene,與渲染模型的主Scene分離,并且對ColorMap使用正交相機而不是透視相機
正交相機的作用
在 Three.js 中,正交相機(OrthographicCamera)與透視相機(PerspectiveCamera)不同,它不會根據物體距離相機的遠近來縮放物體,所有物體無論遠近看起來大小都一樣。在本文的代碼中,正交相機的作用是:
固定 UI 元素的位置:通過正交相機渲染的元素會固定在屏幕上的某個位置,不會隨著主相機的移動而移動,非常適合實現 HUD (平視顯示器)、UI 面板等固定元素。
獨立的渲染空間:正交相機創建了一個獨立的 2D 渲染空間,坐標系統從 - 1 到 1,便于精確控制 UI 元素的位置和大小。
彩色圖例的繪制過程
彩色圖例的繪制主要通過以下步驟實現:
-
創建顏色映射紋理:
useEffect(() => {const lut = new Lut()lut.setColorMap(colorMap)lut.setMax(2000)lut.setMin(0)const canvas = lut.createCanvas()const newTexture = new THREE.CanvasTexture(canvas)newTexture.colorSpace = THREE.SRGBColorSpacesetTexture(newTexture)return () => newTexture.dispose() }, [colorMap])
這部分代碼使用
Lut
(Lookup Table) 類創建一個顏色映射表,并將其轉換為 Canvas 紋理。 -
創建 Sprite 元素:
return (<sprite ref={spriteRef} scale={[0.125, 1, 1]}><spriteMaterial map={texture} /></sprite> )
Sprite 是 Three.js 中始終面向相機的 2D 元素,非常適合用于 UI 顯示。這里將前面創建的顏色映射紋理應用到 Sprite 上。
-
使用正交相機渲染:
const orthoCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 1, 2) orthoCamera.position.set(0.5, 0, 1)const tempScene = new THREE.Scene() if (spriteRef.current) {tempScene.add(spriteRef.current) }gl.render(tempScene, orthoCamera)
這部分代碼創建了一個臨時場景,只包含圖例 Sprite 元素,并使用正交相機進行渲染。由于正交相機的特性,無論主相機如何移動,圖例都會固定在屏幕上的相同位置。
圖例不隨模型移動的原理
圖例不隨模型移動的關鍵在于:
-
獨立的渲染流程:代碼中通過
useFrame
鉤子手動控制渲染過程,先渲染主場景,再單獨渲染圖例:gl.render(scene, camera) // 渲染主場景(使用透視相機) gl.render(tempScene, orthoCamera) // 渲染圖例(使用正交相機)
-
分離的場景管理:圖例被添加到一個臨時場景
tempScene
中,與主場景scene
分離,避免受到主場景中相機控制和模型變換的影響。 -
固定的正交相機設置:正交相機的位置和投影參數被固定設置:
const orthoCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 1, 2) orthoCamera.position.set(0.5, 0, 1)
這種設置使得圖例在屏幕上的位置不會隨著主相機的移動而改變,始終保持在固定位置。
固定圖例代碼?
export const FixedLegend = () => {const { gl } = useThree()const spriteRef = useRef<THREE.Sprite>(null)const { colorMap } = useContext(ColorMapText) as ColorMapTextTypeconst [texture, setTexture] = useState<THREE.CanvasTexture | null>(null)// 創建和更新顏色圖例紋理useEffect(() => {const lut = new Lut()lut.setColorMap(colorMap)lut.setMax(2000)lut.setMin(0)const canvas = lut.createCanvas()const newTexture = new THREE.CanvasTexture(canvas)newTexture.colorSpace = THREE.SRGBColorSpacesetTexture(newTexture)return () => newTexture.dispose()}, [colorMap])// 使用useFrame控制渲染過程useFrame(() => {// 清除自動清除標志,我們將手動控制清除gl.autoClear = false// 渲染圖例(使用正交相機)const orthoCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 1, 2)orthoCamera.position.set(0.5, 0, 1)// 創建一個臨時場景只包含圖例const tempScene = new THREE.Scene()if (spriteRef.current) {tempScene.add(spriteRef.current)}gl.render(tempScene, orthoCamera)})if (!texture) return nullreturn (<sprite ref={spriteRef} scale={[0.125, 1, 1]}><spriteMaterial map={texture} /></sprite>)
}
?GUI切換colorMap
這里快速說一下,? ?通過這個方法const gui = new GUI()可以快速創建一個位于右上角的可視化操作面板,通過配置相關參數,可以動態控制threejs中的參數
?
export const ColorMapGUI = () => {
const { setColorMap} = useContext(ColorMapText) as ColorMapTextTypeconst guiRef = useRef<GUI | null>(null)useEffect(() => {const gui = new GUI()guiRef.current = guiconst params = {colorMap: 'rainbow',}gui.add(params, 'colorMap', ['rainbow','cooltowarm','blackbody','grayscale',]).onChange((value: string) => {setColorMap(value)})return () => {if (guiRef.current) {guiRef.current.destroy()}}}, [])return (<></>)
}
?完整代碼
Model3D組件:負責加載 3D 模型并處理頂點顏色映射。它使用?
useEffect
?鉤子來加載模型數據,并在顏色映射變化時更新頂點顏色。FixedLegend組件:創建顏色條 UI,顯示當前使用的顏色映射。它使用正交相機單獨渲染,確保在場景中始終可見。
ColorMapGUI:切換colorMap類型
ColorMapProvider:提供共享colorMap數據
import { useRef, useState, useEffect, createContext, useContext, type ReactNode } from 'react'
import { useFrame, useThree } from '@react-three/fiber'
import * as THREE from 'three'
import { Lut } from 'three/examples/jsm/math/Lut.js'
import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js'interface PressureGeometry extends THREE.BufferGeometry {attributes: {position: THREE.BufferAttributenormal?: THREE.BufferAttributepressure: THREE.BufferAttributecolor: THREE.BufferAttribute}
}export const Model3D = () => {const meshRef = useRef<THREE.Mesh>(null)const [geometry, setGeometry] = useState<PressureGeometry | null>(null)const { colorMap } = useContext(ColorMapText) as ColorMapTextTypeconst lutRef = useRef<Lut>(new Lut())useEffect(() => {const loader = new THREE.BufferGeometryLoader()loader.load('models/json/pressure.json', (geometry) => {const geometry2 = geometry as unknown as PressureGeometrygeometry.center(); // 確保模型居中geometry.computeVertexNormals(); // 計算法線以正確顯示光照const colors = []for (let i = 0, n = geometry.attributes.position.count; i < n; ++i) {colors.push(1, 1, 1)}geometry.setAttribute('color',new THREE.Float32BufferAttribute(colors, 3),)setGeometry(geometry2)})}, [])useEffect(() => {if (!geometry) returnconst lut = lutRef.currentlut.setColorMap(colorMap)lut.setMax(2000)lut.setMin(0)const pressures = geometry.attributes.pressureconst colors = geometry.attributes.colorconst color = new THREE.Color()for (let i = 0; i < pressures.array.length; i++) {const colorValue = pressures.array[i]color.copy(lut.getColor(colorValue)).convertSRGBToLinear()colors.setXYZ(i, color.r, color.g, color.b)}colors.needsUpdate = true}, [colorMap, geometry])if (!geometry) return nullreturn (<mesh ref={meshRef} geometry={geometry}><meshLambertMaterialside={THREE.DoubleSide}color={0xf5f5f5}vertexColors={true}/></mesh>)
}export const FixedLegend = () => {const { gl } = useThree()const spriteRef = useRef<THREE.Sprite>(null)const { colorMap } = useContext(ColorMapText) as ColorMapTextTypeconst [texture, setTexture] = useState<THREE.CanvasTexture | null>(null)// 創建和更新顏色圖例紋理useEffect(() => {const lut = new Lut()lut.setColorMap(colorMap)lut.setMax(2000)lut.setMin(0)const canvas = lut.createCanvas()const newTexture = new THREE.CanvasTexture(canvas)newTexture.colorSpace = THREE.SRGBColorSpacesetTexture(newTexture)return () => newTexture.dispose()}, [colorMap])// 使用useFrame控制渲染過程useFrame(() => {// 清除自動清除標志,我們將手動控制清除gl.autoClear = false// 渲染圖例(使用正交相機)const orthoCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 1, 2)orthoCamera.position.set(0.5, 0, 1)// 創建一個臨時場景只包含圖例const tempScene = new THREE.Scene()if (spriteRef.current) {tempScene.add(spriteRef.current)}gl.render(tempScene, orthoCamera)})if (!texture) return nullreturn (<sprite ref={spriteRef} scale={[0.125, 1, 1]}><spriteMaterial map={texture} /></sprite>)
}
export const ColorMapGUI = () => {
const { setColorMap} = useContext(ColorMapText) as ColorMapTextTypeconst guiRef = useRef<GUI | null>(null)useEffect(() => {const gui = new GUI()guiRef.current = guiconst params = {colorMap: 'rainbow',}gui.add(params, 'colorMap', ['rainbow','cooltowarm','blackbody','grayscale',]).onChange((value: string) => {setColorMap(value)})return () => {if (guiRef.current) {guiRef.current.destroy()}}}, [])return (<></>)
}
type ColorMapTextType = {colorMap: string,setColorMap: (value: string) => void,
}
const ColorMapText = createContext<ColorMapTextType|undefined>(undefined)export const ColorMapProvider = ({ children }: { children: ReactNode }) => {const [colorMap, setColorMap] = useState<string>('rainbow')const value={colorMap,setColorMap,}return (<ColorMapText.Provider value={value}>{children}</ColorMapText.Provider>)
}
提供Canvas、光源、并加載模型和圖例組件?
import { OrbitControls } from '@react-three/drei'
import { Canvas } from '@react-three/fiber'
import { ColorMapGUI, FixedLegend, ColorMapProvider, Model3D } from './ColorMap'export const ThreeJsExample = () => {return (<ColorMapProvider><div style={{ width: '100vw', height: '100vh' }}><Canvascamera={{ position: [0, 0, 10], fov: 60 }}gl={{ antialias: true }}><ambientLight intensity={0.5} /><directionalLight position={[0, 0, 1]} intensity={3} /><pointLight position={[3, 0, 0]} /><Model3D /><FixedLegend /><ColorMapGUI /><OrbitControls /></Canvas></div></ColorMapProvider>)
}
總結
這個項目展示了如何結合React-Three-Fiber和Three.js創建專業的科學可視化應用。關鍵點包括:
正確處理自定義幾何屬性
實現不隨場景旋轉的固定UI元素
構建靈活的顏色映射系統
優化渲染性能
這種模式可以擴展到其他科學可視化場景,如溫度場、流速場等的可視化。