版本
v1.1 批量渲染模型、支持hdr环境贴图、支持环境雾效果、支持导入gltf和glb格式,以及解压缩功能、地面表格和操作轴辅助
v1.2 增加模型的点击事件,以及点击模型边缘高亮和弹窗、
使用方法
- 下载 ThreeTool.js 文件,将其放置在项目目录中
- 在 HTML 文件中引入 ThreeTool.js 文件
- 在 JavaScript 代码中创建 ThreeTool 对象,并传入 canvas 元素
- 调用 ThreeTool 对象的 init 方法初始化 Three.js 环境
- 调用 ThreeTool 对象的 addObject 方法添加对象到场景中
- 调用 ThreeTool 对象的 animate 方法开始动画循环
示例代码
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import Stats from "three/examples/jsm/libs/stats.module.js";
import { FontLoader } from "three/examples/jsm/loaders/FontLoader.js";
import { TextGeometry } from "three/examples/jsm/geometries/TextGeometry.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { RGBELoader } from "three/examples/jsm/loaders/RGBELoader.js";
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js";
import { CSS3DObject, CSS3DRenderer } from "three/examples/jsm/renderers/CSS3DRenderer.js";
import { AmbientLight, DirectionalLight, DoubleSide } from "three";
import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js";
import { OutlinePass } from "three/examples/jsm/postprocessing/OutlinePass.js";
import { ShaderPass } from "three/examples/jsm/postprocessing/ShaderPass.js";
import { GammaCorrectionShader } from "three/examples/jsm/shaders/GammaCorrectionShader.js";
import { FXAAShader } from "three/examples/jsm/shaders/FXAAShader.js";
interface glbTypeProps {
key: string;
position: { x: number; y: number; z: number };
rotation: { x: number; y: number; z: number };
scale?: { x: number; y: number; z: number };
zip?: string;
name: string;
path: string;
}
interface DOMProps {
position: { x: number; y: number; z: number };
dom: string;
}
export class ThreeTool {
public camera: THREE.PerspectiveCamera;
public scene: THREE.Scene;
public renderer: THREE.WebGLRenderer;
public composer: EffectComposer;
public renderPass: RenderPass;
public outlinePass: OutlinePass;
public selectedMesh: THREE.Object3D[] = [];
public rayCaster: THREE.Raycaster;
public mouse: THREE.Vector2;
public labelRenderer: CSS3DRenderer;
constructor() {
this.renderer = this.initRenderer();
this.scene = this.initScene();
this.camera = this.initCamera();
this.initOrbitControls();
this.rayCaster = new THREE.Raycaster();
this.mouse = new THREE.Vector2();
this.composer = new EffectComposer(this.renderer);
this.renderPass = new RenderPass(this.scene, this.camera);
this.outlinePass = new OutlinePass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
this.scene,
this.camera,
this.selectedMesh,
);
this.setupOutlinePass();
this.setupPostProcessing();
window.addEventListener("resize", this.onWindowResize.bind(this), true);
this.labelRenderer = this.createCss3DRender();
}
public createCss3DRender() {
const labelRenderer = new CSS3DRenderer();
labelRenderer.setSize(window.innerWidth, window.innerHeight);
labelRenderer.domElement.style.position = "absolute";
labelRenderer.domElement.style.top = "0px";
labelRenderer.domElement.style.pointerEvents = "none";
document.body.appendChild(labelRenderer.domElement);
return labelRenderer;
}
public createDialogHtml(DOM: DOMProps) {
const labelDiv = document.createElement("div");
labelDiv.innerHTML = DOM.dom;
labelDiv.style.fontSize = "0.2rem";
labelDiv.style.maxWidth = "30px";
labelDiv.style.maxHeight = "10px";
labelDiv.style.overflow = "hidden";
labelDiv.style.textOverflow = "ellipsis";
const boxObject = new CSS3DObject(labelDiv);
boxObject.position.set(DOM.position.x, DOM.position.y, DOM.position.z);
this.scene.add(boxObject);
}
public initScene(type?: string): THREE.Scene {
const scene = new THREE.Scene();
switch (type) {
case "lineFog":
scene.fog = new THREE.Fog(0xcccccc, 0.1, 60);
break;
case "logFog":
scene.fog = new THREE.FogExp2(0xcccccc, 0.02);
break;
default:
scene.fog = null;
}
return scene;
}
public initHdrSky(url: string = "") {
const loader = new RGBELoader();
const _this = this;
loader.load(
url,
function (texture: any) {
texture.mapping = THREE.EquirectangularReflectionMapping;
_this.scene.environment = texture;
_this.scene.background = texture;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
},
undefined,
(error: any) => {
console.error("Failed to load HDR texture:", error);
},
);
}
public initImgSky(url: string = "") {
const _this = this;
const cubeTextureLoader = new THREE.CubeTextureLoader();
const environmentMapTexture = cubeTextureLoader.setPath(url).load(
["px.png", "nx.png", "py.png", "ny.png", "pz.png", "nz.png"],
() => {
_this.scene.background = environmentMapTexture;
},
undefined,
(e: any) => {
console.log(e);
},
);
}
public createMesh(cubeGeometry: any, cubeMaterial: any) {
const cube = new THREE.Mesh(cubeGeometry, cubeMaterial);
this.scene.add(cube);
}
public initAxesHelper(lg: number) {
const axesHelper = new THREE.AxesHelper(lg);
this.scene.add(axesHelper);
}
private onWindowResize() {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
}
public setupOutlinePass() {
this.composer.addPass(this.renderPass);
this.outlinePass.renderToScreen = true;
this.outlinePass.edgeStrength = 10.0;
this.outlinePass.edgeGlow = 1;
this.outlinePass.usePatternTexture = false;
this.outlinePass.edgeStrength = 30;
this.outlinePass.edgeThickness = 15;
this.outlinePass.downSampleRatio = 1;
this.outlinePass.pulsePeriod = 3;
this.outlinePass.visibleEdgeColor.set(0x00ff00);
this.outlinePass.hiddenEdgeColor.set(0x000000);
this.outlinePass.clear = true;
}
private setupPostProcessing() {
this.composer.addPass(this.renderPass);
this.composer.addPass(this.outlinePass);
const effectFXAA = new ShaderPass(FXAAShader);
effectFXAA.uniforms["resolution"].value.set(1 / window.innerWidth, 1 / window.innerHeight);
effectFXAA.renderToScreen = true;
this.composer.addPass(effectFXAA);
const gammaPass = new ShaderPass(GammaCorrectionShader);
this.composer.addPass(gammaPass);
}
public initGltfLoader(
urls: glbTypeProps[],
onProgress?: (process: number) => void,
onerror?: (error: string) => void,
) {
urls.map((item) => {
const { name, path, position, rotation, zip, scale } = item;
const loader = new GLTFLoader().setPath(path);
const _this = this;
if (zip) {
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath(zip);
loader.setDRACOLoader(dracoLoader);
}
loader.load(
name,
function (gltf: any) {
const ambientLight = new AmbientLight(0xffffff, 1);
_this.scene.add(ambientLight);
const dirLight = new DirectionalLight(0xffffff, 5);
dirLight.position.set(0, 1, 0);
_this.scene.add(dirLight);
gltf.scene.traverse((child: any) => {
if (child.isMesh) {
child.renderOrder = 20;
child.material.side = DoubleSide;
child.castShadow = true;
child.receiveShadow = true;
child.frustumCulled = false;
}
});
gltf.scene.position.set(position.x, position.y, position.z);
gltf.scene.rotation.set(rotation.x, rotation.y, rotation.z);
if (scale) {
gltf.scene.scale.set(scale.x, scale.y, scale.z);
}
_this.scene.add(gltf.scene);
},
function (xhr: any) {
const process = ((xhr.loaded / xhr.total) * 100).toFixed(2);
const value = parseFloat(process);
if (onProgress) {
onProgress(value);
}
},
function (error: any) {
const errorData = error.message;
if (onerror) {
onerror(errorData);
}
},
);
});
}
public initGridHelper(l: number, w: number, color: number, bgcolor: number) {
const gridHelper = new THREE.GridHelper(l, w, color, bgcolor);
gridHelper.material.opacity = 0.7;
gridHelper.material.depthWrite = false;
gridHelper.material.transparent = true;
this.scene.add(gridHelper);
}
public initRenderer(): THREE.WebGLRenderer {
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
return renderer;
}
public initCamera(): THREE.PerspectiveCamera {
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
return camera;
}
public initOrbitControls() {
const controls = new OrbitControls(this.camera, this.renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.autoRotate = true;
controls.update();
}
public initStats(container: HTMLDivElement) {
const stats = new Stats();
stats.dom.style.position = "absolute";
stats.dom.style.left = "0";
stats.dom.style.zIndex = "100";
container.appendChild(stats.dom);
return stats;
}
public initAxisHelper(axesLength: number = 150, showText: boolean = true) {
const helper = new THREE.AxesHelper(axesLength);
if (showText) {
const loader = new FontLoader();
let meshX = new THREE.Mesh();
let meshY = new THREE.Mesh();
let meshZ = new THREE.Mesh();
loader.load("fonts/optimer_regular.typeface.json", (font) => {
meshX = this.createText("X", font);
meshY = this.createText("Y", font);
meshZ = this.createText("Z", font);
meshX.position.x = 12;
meshY.position.y = 12;
meshZ.position.z = 12;
this.scene.add(meshX);
this.scene.add(meshY);
this.scene.add(meshZ);
});
}
this.scene.add(helper);
}
private createText(content: string, font: any) {
const textGeometry = new TextGeometry(content, {
font: font,
size: 1,
depth: 0.1,
curveSegments: 1,
});
textGeometry.center();
const textMaterial = new THREE.MeshPhongMaterial({ color: 0xffffff, flatShading: true });
const mesh = new THREE.Mesh(textGeometry, textMaterial);
return mesh;
}
}
组件内使用
import { useRef, useEffect, useState } from 'react'
import * as THREE from 'three'
import { ThreeTool } from '../../../../sdk/ThreeTool'
import { Spin } from 'antd'
const ThreePractice = () => {
const myDialogRef = useRef<HTMLDivElement>(null)
const [progress, setProgress] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const statsRef = useRef<Stats>()
const instance = new ThreeTool()
instance.camera.position.set(30, 24, 16)
instance.camera.lookAt(0, 0, 0)
instance.initAxisHelper()
instance.initGridHelper(50, 50, 0xffffff, 0xffffff)
instance.initImgSky('/3dModel/png/pureSky/4k/')
const straightLight = new THREE.DirectionalLight(0xffffff, 5)
straightLight.position.set(20, 20, 20)
straightLight.renderOrder = 1
instance.scene.add(straightLight)
instance.initScene('lineFog')
const onProgress = (val: number) => {
}
const handleClick = (event: MouseEvent) => {
instance.mouse.x = (event.clientX / window.innerWidth) * 2 - 1
instance.mouse.y = -(event.clientY / window.innerHeight) * 2 + 1
const rayOrigin = instance.camera.position.clone()
const rayDirection = new THREE.Vector3()
instance.camera.getWorldDirection(rayDirection)
instance.rayCaster.set(rayOrigin, rayDirection)
const selectableObjects = instance.scene.children.filter(
(child: any) => child.type === 'Group' || child.type === 'Mesh',
)
const intersects = instance.rayCaster.intersectObjects(selectableObjects, true)
if (intersects.length > 0) {
let selectedObject = intersects[0].object
while (selectedObject.parent && selectedObject.renderOrder === 0) {
selectedObject = selectedObject.parent
}
let selectedObjects = []
if (selectedObject?.parent?.type === 'Group') {
selectedObject.parent.traverse(function (obj: any) {
if (obj.type === 'Mesh') {
selectedObjects.push(obj)
}
})
} else if (!selectedObject.parent) {
selectedObject.children.forEach((obj: any) => {
if (obj.type === 'Mesh') {
selectedObjects.push(obj)
}
})
} else {
selectedObjects.push(selectedObject)
}
instance.selectedMesh = selectedObjects
instance.outlinePass.selectedObjects = selectedObjects
const HtmlContent = {
dom: `
<style>
.dialog-container {
width: 100%; /* 修改为更小的宽度 */
height: 100%;
background-color: #f0f0f0;
padding: 5px; /* 减小内边距 */
border-radius: 2px;
box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.2);
}
.box-container {
color: #333;
font-family: Arial, sans-serif;
}
.tip-green {
background-color: #e0f7e0;
padding: 2px; /* 减小内边距 */
border-radius: 5px;
}
.line-green {
height: 2px;
background-color: #66bb6a;
margin-top: 5px; /* 减小间距 */
}
.label-value-green {
color: #4caf50;
font-weight: bold;
font-size: 14px; /* 调整字体大小 */
}
.title {
font-size: 4px; /* 调整字体大小 */
margin-bottom: 2px; /* 减小底部间距 */
font-weight: bold;
}
</style>
<div class="box-container">
<div class='tip-green' >
<div class="title">设备名称 :测试</div>
<div class="label-text">
温度 :
<span class="mr5" class='label-value-green'>
50
</span>
<span class='label-value-green'>
正常
</span>
</div>
<div class="label-text">
漏水 :
<span class="mr5" class='label-value-green'>
40
</span>
<span class='label-value-green'>
正常
</span>
</div>
</div>
<div class=line-green></div>`,
position: { x: 0, y: 0, z: 0 },
}
instance.createDialogHtml(HtmlContent)
} else {
console.log('未点击到物体')
}
}
const urls = [
{
key: 'part1',
path: '/3dModel/glb/',
name: 'gymTrainer.glb',
zip: '/draco/',
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
scale: { x: 0.08, y: 0.08, z: 0.08 },
},
]
instance.initGltfLoader(urls, onProgress)
const animate = () => {
requestAnimationFrame(animate)
instance.renderer.render(instance.scene, instance.camera)
statsRef.current && statsRef.current.update()
instance.composer.render()
instance.labelRenderer.render(instance.scene, instance.camera)
}
useEffect(() => {
if (containerRef.current) {
containerRef.current.appendChild(instance.renderer.domElement)
instance.renderer.render(instance.scene, instance.camera)
statsRef.current = instance.initStats(containerRef.current)
animate()
if (instance.labelRenderer) {
instance.labelRenderer.render(instance.scene, instance.camera)
}
containerRef.current.addEventListener('click', handleClick)
}
return () => {
if (containerRef.current) {
containerRef.current.removeEventListener('click', handleClick)
}
}
}, [containerRef])
return (
<>
<div ref={myDialogRef}></div>
<div ref={containerRef} style={{ width: '100px', height: '40px' }}></div>
</>
)
}
export default ThreePractice