Three.js 是一个强大的 JavaScript 3D 库,可以创建令人惊叹的 Web 3D 应用程序。本文将深入分析一个完整的 Three.js 场景管理类 useScene
,它封装了场景初始化、模型加载、事件处理等核心功能。
依赖与导入
js
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import {KTX2Loader} from 'three/examples/jsm/loaders/KTX2Loader';
import {DRACOLoader} from 'three/examples/jsm/loaders/DRACOLoader'
import {MeshoptDecoder} from 'three/examples/jsm/libs/meshopt_decoder.module';
import Stats from "stats-gl";
import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader.js';
import { gsap } from "gsap";
import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js';
-
three:核心 3D 引擎。
-
OrbitControls:相机轨道控制器(旋转/平移/缩放)。
-
GLTFLoader:加载 glTF/GLB 模型。
-
KTX2Loader:加载 KTX2 压缩纹理(Basis Universal)。
-
DRACOLoader:加载 Draco 压缩几何。
-
MeshoptDecoder:Meshopt 顶点/索引压缩解码。
-
stats-gl:FPS/GPU 性能面板。
-
RGBELoader:HDR 贴图加载器(环境光照)。
-
gsap:动画库(用于相机/目标缓动)。
-
lil-gui:轻量 GUI 面板(调试、调参)。
useScene总览与成员
js
export class useScene {
constructor(params){ ... }
onListener(event, handler){ ... }
init(){ ... }
initStates(){ ... }
initScene(){ ... }
initCamera(){ ... }
initLight(){ ... }
createPanel(){ ... }
initControls(){ ... }
initRenderer(){ ... }
startRender(){ ... }
cancelAnimation(){ ... }
startAnimation(){ ... }
addRender(name, fun){ ... }
removeRender(name){ ... }
initSize(width, height){ ... }
initLoading(){ ... }
loadModelFile(url){ ... }
mergeModelGeometry(model, bufferName='position'){ ... }
getModelObject(){ ... }
setPosition(model){ ... }
getPosition(model){ ... }
getModelPosition = (name) => { ... }
}
构造器与核心字段
js
constructor(params){
this.gsap = gsap
this.eventTarget = new EventTarget(),
this.loadingManager = new THREE.LoadingManager()//加载资源管理器
this.scene = null
this.camera = null
this.controls = null
this.renderer = null
this.stats = null
this.raycaster = new THREE.Raycaster();
this.width = params.width || window.innerWidth
this.height = params.height || window.innerHeight
this.container = params.container
this.renderFunctions = {}
this.init()
this.initStates()
}
-
eventTarget:统一事件总线,向外派发加载进度、点击拾取等事件。
-
loadingManager:集中监听资源加载生命周期。
-
raycaster :用于点击拾取(鼠标射线相交)。
-
renderFunctions:自定义渲染回调注册表(按帧执行)。
-
container:渲染画布挂载的 DOM 容器(也是监听点击的区域)。
初始化流程
js
init (){
this.initScene()
this.initCamera()
this.initControls()
this.initRenderer()
this.startRender()
this.initLight()
this.getModelObject()
this.initLoading()
window.addEventListener('resize', ()=> this.initSize());
}
顺序要点:
- 先建场景/相机/控制器,再创建渲染器并立即开始渲染循环。
- 加上环境光、点击拾取与加载器事件。
- 监听
resize
,自适应窗口或容器尺寸。
性能状态面板
js
initStates(){
const stats = new Stats({ horizontal: false, trackGPU: true });
this.container.appendChild(stats.dom);
this.stats?.init(this.renderer);
this.stats = stats
}
-
将 stats-gl 面板插入容器。
-
trackGPU: true
开 GPU 指标(依赖浏览器支持)。
场景初始化
initScene()
方法创建了一个基础 Three.js 场景,并设置了环境贴图
js
initScene(){
const scene = new THREE.Scene();
scene.name = "scene"
scene.receiveShadow = true;
scene.castShadow = true
scene.background = new THREE.Color('#000F25')
scene.environment = new RGBELoader().load('/hdr/drachenfels_cellar_1k.hdr' ,(texture)=>{
scene.environment.mapping = THREE.EquirectangularReflectionMapping;
scene.environmentIntensity = 1
scene.environment = texture;
})
this.scene = scene;
}
关键点:
- 设置了阴影接收和投射
- 使用 HDR 环境贴图增强场景真实感
- 通过
EquirectangularReflectionMapping
实现环境反射
相机初始化
js
initCamera(){
const camera = new THREE.PerspectiveCamera( 45, this.width / this.height, 0.01, 100000 );
camera.position.set(0,0,0)
this.camera = camera
}
- 45° 视角,近平面 0.01,远平面 10w(配合对数深度缓冲)。
- 初始相机位置在原点
(0,0,0)
;通常实际会在加载模型后用setPosition
或getModelPosition
重新布置。
控制器设置
js
initControls(){
const controls = new OrbitControls( this.camera, this.container);
controls.enableDamping = true;
controls.dampingFactor = 0.5;
controls.zoomSpeed = 2;
controls.enableZoom = true;//是否缩放
controls.autoRotate = false;
controls.minPolarAngle = 0;
controls.maxPolarAngle = (Math.PI / 180) * 80;
controls.minAzimuthAngle = -Infinity;
controls.maxAzimuthAngle = Infinity;
controls.update();
this.controls = controls
}
-
阻尼开启,阻尼系数 0.5(手感偏重)。
-
缩放速度 2,禁止自动旋转。
-
俯仰角 限制
0° ~ 80°
,方位角无限制。 -
重要 :第二参数绑定在
container
上,即控制器监听容器事件。通常也可绑定renderer.domElement
;此处选container
便于拾取 和控制共用同一区域。
初始化灯光
js
initLight(){
const lightsGroup = new THREE.Group();
lightsGroup.name = "lights"
const light = new THREE.AmbientLight('#b0ccf0',1.0);
light.name = '环境光'
lightsGroup.add(light)
this.scene.add(lightsGroup)
}
-
仅添加了环境光(浅蓝、强度 1.0)。
-
将所有灯光放入
lights
组,方便统一管理/销毁。
GUI 面板
js
createPanel(){
return new GUI({ width: 250 });
}
返回一个 lil-gui 面板实例,未默认挂载任何参数,供上层按需使用。
渲染器初始化
js
initRenderer(){
const renderer = new THREE.WebGLRenderer( {
antialias: true,
alpha: true,
powerPreference: 'high-performance',
premultipliedAlpha: true,
preserveDrawingBuffer: true, // 允许截图
logarithmicDepthBuffer: true, // 是否使用对数深度缓存。如果要在单个场景中处理巨大的比例差异
});
renderer.setPixelRatio( window.devicePixelRatio );
renderer.setSize(this.width, this.height );
renderer.sortObjects = true;//启用对渲染对象的排序。这通常用于确保渲染顺序正确,以避免深度排序问题。
renderer.autoClear = true;//每个渲染帧之前自动清除渲染目标
renderer.autoClearColor = true;//自动清除渲染目标的颜色缓冲区。
renderer.autoClearDepth = true;//自动清除渲染目标的深度缓冲区。
renderer.autoClearStencil = true;// 自动清除渲染目标的模板缓冲区。
renderer.localClippingEnabled = true;// 启用局部剪裁,允许使用
renderer.outputColorSpace = THREE.SRGBColorSpace;//定义渲染器的输出编码。默认为THREE.SRGBColorSpace LinearSRGBColorSpace outputEncoding
renderer.toneMapping = THREE.LinearToneMapping;//禁用色调映射。色调映射用于模拟相机的曝光。
renderer.toneMappingExposure = 1.5;
renderer.shadowMap.enabled = true
// renderer.shadowMap.type = THREE.PCFShadowMap;
renderer.physicallyCorrectLights = true;//启用物理正确的光照计算
this.container.appendChild( renderer.domElement );
this.renderer = renderer
}
关键项解读:
- logarithmicDepthBuffer:解决超大场景 z-fighting(更耗性能)。
- preserveDrawingBuffer :允许截图(会降低性能;截图完可以改回
false
)。 - sortObjects:启用对象排序,半透明/复杂层次时更安全。
- localClippingEnabled:允许局部裁剪。
- 色彩空间 :
SRGBColorSpace
(现代 three 写法)。 - 色调映射:线性 + 曝光 1.5(你可按需求换 ACESFilmic)。
- 物理光照 :
physicallyCorrectLights = true
,配合 PBR/环境贴图更真实
渲染循环
js
startRender(){
Object.keys(this.renderFunctions).forEach(key => {
this.renderFunctions[key]()
})
this.renderer.render(this.scene, this.camera);
this.controls.update();
this.stats?.update();
this.animationId = requestAnimationFrame(this.startRender.bind(this))
}
cancelAnimation(){
cancelAnimationFrame(this.animationId)
}
// 别名:用于"替换环境贴图后重新启动"
startAnimation(){
this.startRender()
}
-
每帧先执行自定义渲染任务 (
renderFunctions
),再渲染画面。 -
更新控制器阻尼、更新性能面板。
-
cancelAnimation()
用于暂停,startAnimation()
用于启动渲染
插拔式帧回调
js
addRender(name, fun){
this.renderFunctions[name] = fun;
}
removeRender(name){
this.renderFunctions[name] ? delete this.renderFunctions[name] : false;
}
以名字注册/移除帧级任务,避免重复与泄漏,适合粒子、后期、动画等模块化更新。
自适应尺寸
js
initSize(width, height){
this.width = width || this.container.clientWidth || window.innerWidth;
this.height = height || this.container.clientHeight || window.innerHeight
this.camera.aspect = this.width / this.height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(this.width, this.height);
}
-
支持外部传参或自动从容器/窗口取值。
-
更新相机投影与渲染器大小。
资源加载事件
js
initLoading(){
this.loadingManager.onStart = (url, loaded, total) => {
this.eventTarget?.dispatchEvent(new CustomEvent('onStart', {
detail: { url, loaded, total, progress: parseFloat(loaded / total * 100).toFixed(2) }
}))
}
this.loadingManager.onProgress = (url, loaded, total) => {
this.eventTarget?.dispatchEvent(new CustomEvent('onProgress', {
detail: { url, loaded, total, progress: parseFloat(loaded / total * 100).toFixed(2) }
}))
}
this.loadingManager.onLoad = () => {
this.eventTarget?.dispatchEvent(new CustomEvent('onLoad'))
}
}
通过 EventTarget 对外派发 onStart
/ onProgress
/ onLoad
,便于 UI 进度条与日志。
模型加载
js
loadModelFile(url){
const dracoLoader = new DRACOLoader();
const ktx2Loader = new KTX2Loader();
dracoLoader.setDecoderPath(`${location.href}/draco/gltf/`);
dracoLoader.setDecoderConfig({ type: "js" });
dracoLoader.preload();
const loader = new GLTFLoader(this.loadingManager);
loader.setKTX2Loader(ktx2Loader);
loader.setMeshoptDecoder(MeshoptDecoder);
loader.setDRACOLoader(dracoLoader);
return loader.loadAsync(url, ({ loaded, total }) => {
this.eventTarget?.dispatchEvent(new CustomEvent('progress', {
detail: { url, loaded, total, progress: parseFloat(loaded / total * 100).toFixed(2) }
}))
})
}
-
Draco:设置解码路径(需放置 decoder js/wasm 资源)。
-
KTX2 :直接设置给
GLTFLoader
,用于解析 KTX2 纹理。 -
Meshopt:减少模型体积并加速加载。
-
返回
Promise
(gltf
对象),并在progress
回调继续派发自定义进度事件。
点击拾取
js
getModelObject(){
this.container.addEventListener('click', (e)=>{
e.stopPropagation();
const mouse = {x: 0, y: 0}
mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
this.raycaster.setFromCamera(mouse, this.camera);
const intersects = this.raycaster.intersectObjects(this.scene.children, true);
if (intersects.length > 0) {
const clickedObject = intersects[0];
this.eventTarget?.dispatchEvent(new CustomEvent('click', {
detail: {
model: clickedObject.object,
point: clickedObject.point,
info: clickedObject
}
}));
}
});
}
-
在 container 上监听
click
。 -
将屏幕坐标映射到 NDC ,通过
Raycaster
与场景递归相交。 -
命中后派发
click
事件,返回:model
:命中的Mesh/Object3D
point
:射线命中点世界坐标info
:three.js 相交结果对象
setPosition
js
setPosition(model){
const boundingBox = new THREE.Box3().setFromObject(model);
const boundingSphere = new THREE.Sphere()
boundingBox.getBoundingSphere(boundingSphere)
const [leng,width,height] = [
boundingBox.max.x - boundingBox.min.x,
boundingBox.max.z - boundingBox.min.z,
boundingBox.max.y - boundingBox.min.y
]
const diagonal = Math.sqrt(Math.sqrt(leng ** 2 + width ** 2) ** 2 + height ** 2);
const { x,y,z } = boundingSphere.center
this.camera.position.set(boundingBox.max.x + diagonal, diagonal, boundingBox.max.z + diagonal);
this.controls.target.set(x,y,z)
this.controls.object.updateProjectionMatrix()
this.camera.updateProjectionMatrix()
}
计算 包围盒/包围球 ,将相机放到盒子对角线延伸 位置,target
指向模型中心,确保整体入镜。
getPosition
js
getPosition(model){
const boundingBox = new THREE.Box3().setFromObject(model);
const boundingSphere = new THREE.Sphere()
boundingBox.getBoundingSphere(boundingSphere)
return boundingSphere.center
}
返回给定模型中心点。
getModelPosition
js
getModelPosition = (name) => {
const model = this.scene.getObjectByName(name)
if(!model){ return }
const box = new THREE.Box3().setFromObject(model)
const size = new THREE.Vector3()
box.getSize(size)
const maxSize = Math.max(size.x, size.y, size.z)
const distance = maxSize * 2
const center = new THREE.Vector3()
box.getCenter(center)
const direction = new THREE.Vector3(0, 0, 1)
const newCameraPos = new THREE.Vector3().copy(center).add(direction.multiplyScalar(distance))
// gsap.to(this.camera.position,{ x: newCameraPos.x, y: newCameraPos.y + 1, z: newCameraPos.z, duration:1, onUpdate: () => this.controls.update() })
// gsap.to(this.controls.target,{ x: center.x, y: center.y, z: center.z, duration:1 })
}
-
通过名称定位目标,计算其中心与尺寸,沿 +Z 方向推远到合适距离。
-
缓动动画已注释,打开即可实现平滑对焦。
事件对外接口
js
onListener (event, handler){
this.eventTarget?.addEventListener(event, handler);
}
可监听的事件包括:
- 加载 :
onStart
/onProgress
/onLoad
(initLoading()
) - 模型加载进度 :
progress
(loadModelFile()
的第二个回调) - 点击拾取 :
click
(getModelObject()
)
使用示例
init.js
js
import { useScene } from '@/hooks/useThreeModel';
export const initScene = (params) => {
const app = new useScene({
width: params.container.clientWidth,
height: params.container.clientHeight,
container: params.container,
})
//全局变量
window.ThreeModel = app;
// 载入进度
app.onListener('onStart',() => {
info.progress = 0;
info.isloadingManager = true
})
app.onListener('onProgress',({detail:{loaded,total,progress}}) => {
info.Loadtext = "解析中..."
const num = (loaded / total * 100).toFixed(2);
info.progress = parseFloat(num);
})
app.onListener('onLoad', () => {
info.isloadingManager = false;
info.Loadtext = "加载完成"
})
app.onListener('progress',({detail:{progress}})=>{
info.Loadtext = "下载中..."
info.progress = parseFloat(progress);
})
loadModelAsync('/model/Michelle.glb')
}
export const loadModelAsync = async (url) => {
try {
const { scene } = await ThreeModel.loadModelFile(url);
ThreeModel.scene.add(scene)
} catch (error) { console.log(error) }
}
index.vue
js
<script setup>
import { nextTick, onMounted, reactive, useTemplateRef } from 'vue';
import {initScene} from '@/three/init.js';
const modeView = useTemplateRef('modeView');
const info = reactive({
})
onMounted(()=>{
nextTick(()=>{
initScene({
container: modeView.value
})
})
})
</script>
<template>
<div class="model-view" ref="modeView"></div>
</template>
<style lang="less" scoped>
.model-view{
width: 100%;
height: 100%;
background-color: #000F25;
}
</style>