使用 threejs 实现真实世界简单模拟,不限于模型加载
效果
如下,分别是早上6点和下午6点和我写完文章时的截图
目录结构
client
|---public
|---draco // .glb 模型解码文件
|---models // .glb 模型
|---textures // 贴图
|---src
|---api
|---websocket.ts
|---component
|---Clock.vue // 时间组件
|---utils
|---getDate.ts // 自己封装的时间格式化
|---three_home.ts // threejs 主函数
|---views
|---Home.vue // 首页
|---types.ts // 定义类型
|---vite-env.d.ts // 配置
server
index.js
复制代码
实现的功能
1. 客户端功能
- 3D模型加载
- 2D内容创建
- 天空和水和太阳
- 根据真实时间实现的太阳实时变换
- 根据模拟数据模拟的车辆实时运动
- 3D场景的交互
2. 服务端功能
- 开启简易的 websocket 服务,为前端模拟数据
3. 使用到的技术
- vue
- vite
- typescript
- three.js
- websocket
实现
客户端实现
three_home.ts
import
import { onMounted, onUnmounted, ref } from 'vue'
import * as THREE from 'three';
import Stats from 'three/examples/jsm/libs/stats.module';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
import { Water } from 'three/examples/jsm/objects/Water';
import { Sky } from 'three/examples/jsm/objects/Sky';
import { TWEEN } from "three/examples/jsm/libs/tween.module.min.js";
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { CSS2DObject, CSS2DRenderer } from 'three/examples/jsm/renderers/CSS2DRenderer'
import { CarInfoType } from '../types'
import { CarModelsType } from '../types';
复制代码
声明变量
let camera: THREE.PerspectiveCamera
let scene: THREE.Scene
let renderer: THREE.WebGLRenderer
let controls: OrbitControls
let labelRenderer: CSS2DRenderer
let water: Water
let sun: THREE.Vector3
let sky: Sky
let container: HTMLElement | null
let stats: any;
let pmremGenerator: THREE.PMREMGenerator
let renderTarget: THREE.WebGLRenderTarget
// 车辆模型
let carModels: CarModelsType = { car1: null, car2: null, car3: null };
// 页面模型加载进度
let modelsReady = 0
// setinterval 定时器
let timer = 0
// 需要渲染到场景的车子的组
let carGroup: THREE.Group
// 车的三个状态位置
const linePosition = {
0: {
bigin: {
x: -1.5,
y: 22.4,
z: -320
},
mid: {
x: -1.5,
y: 22.4,
z: -130
},
end: {
x: -1.5,
y: 22.4,
z: 60
}
},
1: {
bigin: {
x: 1.5,
y: 22.4,
z: 60
},
mid: {
x: 1.5,
y: 22.4,
z: -130
},
end: {
x: 1.5,
y: 22.4,
z: -320
}
}
}
// 不同车辆,不同车牌
const CarHashMap = {
car1: {
黄: 'url(./chepai/小型车黄牌.png)',
绿: 'url(./chepai/小型车绿牌.png)',
蓝: 'url(./chepai/小型车蓝牌.png)',
},
car2: {
黄: 'url(./chepai/中型车黄牌.png)',
绿: 'url(./chepai/中型车绿牌.png)',
蓝: 'url(./chepai/中型车蓝牌.png)',
},
car3: {
黄: 'url(./chepai/大型车黄牌.png)',
绿: 'url(./chepai/大型车绿牌.png)',
蓝: 'url(./chepai/大型车蓝牌.png)',
}
}
复制代码
导出 init 函数
export function init() {
onMounted(() => {
initScene()
})
onUnmounted(() => {
clear()
})
}
复制代码
太阳 天空 海洋 的初始化
function initSunWater() {
// 太阳
sun = new THREE.Vector3();
// 海洋
const waterGeometry = new THREE.PlaneGeometry(10000, 10000);
water = new Water(
waterGeometry,
{
textureWidth: 512,
textureHeight: 512,
waterNormals: new THREE.TextureLoader().load('./textures/waternormals.jpg', function (texture) {
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
}),
sunDirection: new THREE.Vector3(),
sunColor: 0xffffff,
waterColor: 0x001e0f,
distortionScale: 3.7,
fog: scene.fog !== undefined
}
);
water.rotation.x = - Math.PI / 2;
scene.add(water);
// 天空
sky = new Sky();
sky.scale.setScalar(10000);
scene.add(sky);
const skyUniforms = sky.material.uniforms;
skyUniforms['turbidity'].value = 10;
skyUniforms['rayleigh'].value = 2;
skyUniforms['mieCoefficient'].value = 0.002; // 太阳光晕大小
skyUniforms['mieDirectionalG'].value = 0.8;
updateSun()
}
复制代码
太阳的更新
function updateSun() {
if (!sun) return
// 太阳与Y轴 负轴的夹角
const phi = THREE.MathUtils.degToRad(90 - parameters.value.elevation);
// 相当于经度 不需要关心,不对经度做变化
const theta = THREE.MathUtils.degToRad(parameters.value.azimuth);
sun.setFromSphericalCoords(1, phi, theta);
sky.material.uniforms['sunPosition'].value.copy(sun);
water.material.uniforms['sunDirection'].value.copy(sun).normalize();
if (renderTarget !== undefined) renderTarget.dispose();
renderTarget = pmremGenerator.fromScene(sky as any);
scene.environment = renderTarget.texture;
}
复制代码
太阳关联真实时间
const parameters = ref({
elevation: 36 * (new Date().getHours() * 3600 + new Date().getMinutes() * 60 + new Date().getSeconds() - 6 * 3600) / 8640,
azimuth: 180
})
// 每秒计算一次角度,更新太阳位置,如果觉得每秒没必要可以 改为每分钟
timer = setInterval(() => {
let elevation = 36 * (new Date().getHours() * 3600 + new Date().getMinutes() * 60 + new Date().getSeconds() - 6 * 3600) / 8640
parameters.value.elevation = elevation
updateSun()
}, 1000)
复制代码
initScene函数
carGroup = new THREE.Group()
container = document.getElementById('container') as HTMLElement;
// 渲染器
renderer = new THREE.WebGLRenderer({ antialias: true, logarithmicDepthBuffer: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(container.offsetWidth, container.offsetHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.VSMShadowMap
container.appendChild(renderer.domElement);
pmremGenerator = new THREE.PMREMGenerator(renderer);
// 2d渲染器
labelRenderer = new CSS2DRenderer()
labelRenderer.setSize(container.offsetWidth, container.offsetHeight);
labelRenderer.domElement.style.position = 'absolute';
labelRenderer.domElement.style.top = '0';
labelRenderer.domElement.style.pointerEvents = 'none';
container.appendChild(labelRenderer.domElement)
// 场景
scene = new THREE.Scene();
scene.add(carGroup)
// 相机
camera = new THREE.PerspectiveCamera(55, container.offsetWidth / container.offsetHeight, 1, 20000);
camera.position.set(0, 40, -20);
// 水 和 太阳
initSunWater();
// 相机轨道控制器
controls = new OrbitControls(camera, renderer.domElement);
controls.maxPolarAngle = Math.PI * 0.495;
controls.enablePan = false
controls.target.set(0, 10, -100);
controls.maxDistance = 2000.0;
controls.update();
// 性能监视器
stats = Stats();
container.appendChild(stats.dom);
window.addEventListener('resize', onWindowResize);
window.addEventListener('mousedown', onWindowMousedown)
复制代码
模型加载
// loader
const dracoLoader = new DRACOLoader()
dracoLoader.setDecoderPath("./draco/")
const loader = new GLTFLoader()
loader.setDRACOLoader(dracoLoader)
// 桥
loader.load('./models/high_bridge.glb', gltf => {
const model = gltf.scene
model.scale.set(0.1, 0.1, 0.1)
modelsReady += 1
model.traverse((e: any) => {
if (e.isMesh) {
e.castShadow = true
e.receiveShadow = true
e.geometry.computeVertexNormals()
}
})
model.rotation.y = Math.PI / 2
model.position.y -= 3
const model1 = model.clone()
model1.position.z -= 259
model1.position.y -= 0.1
model1.rotation.y = -Math.PI / 2
scene.add(model1)
scene.add(model)
// 开启渲染
animate()
})
// 车
loader.load('./models/cars.glb', gltf => {
const model = gltf.scene.children[0]
model.traverse((e: any) => {
if (e.isMesh) {
e.castShadow = true
e.receiveShadow = true
e.geometry.computeVertexNormals()
}
})
carModels.car1 = model.children[1]
carModels.car2 = model.children[2]
carModels.car3 = model.children[3]
carModels.car1.scale.set(30, 30, 30)
carModels.car2.scale.set(30, 30, 30)
carModels.car3.scale.set(30, 30, 30)
modelsReady += 1
})
// 大飞机
loader.load('./models/plane.glb', gltf => {
const model = gltf.scene.children[0]
model.traverse((e: any) => {
if (e.isMesh) {
e.castShadow = true
e.receiveShadow = true
e.geometry.computeVertexNormals()
}
})
model.scale.set(0.001, 0.001, 0.001)
model.position.set(0, 60, -300)
scene.add(model)
})
复制代码
事件监听---窗口大小发生变化调整
function onWindowResize() {
if (container) {
camera.aspect = container.offsetWidth / container.offsetHeight;
camera.updateProjectionMatrix();
renderer.setSize(container.offsetWidth, container.offsetHeight);
}
}
复制代码
导出获取车流数据函数
export function getCar(ins: CarInfoType) {
if (modelsReady >= 2) {
// 开启车流模拟
beginTraffic(ins)
}
}
复制代码
车流模拟---使用 tweenjs 实现补间动画
function beginTraffic(ins: CarInfoType) {
// 对应车流数据,初始化车辆模型 和车辆信息
const car = (carModels[ins.type] as THREE.Object3D).clone() as THREE.Group
const pos = linePosition[ins.line].bigin
car.position.set(pos.x, pos.z, pos.z)
if (ins.line === 1) car.rotation.y = Math.PI
const position = {
x: linePosition[ins.line].bigin.x,
y: linePosition[ins.line].bigin.y,
z: linePosition[ins.line].bigin.z,
}
// 车牌
let pointLabel: CSS2DObject
// 设置起始位置
const tween1 = new TWEEN.Tween(position)
// 设置结束位置
tween1.to(linePosition[ins.line].mid, ins.middleTimeStamp - ins.beginTimeStamp)
// 动画开始时
tween1.onStart(() => {
// 创建DIV
const img = CarHashMap[ins.type][ins.color]
const innerHTML = ins.licence
let labelDiv = createDiv(img, innerHTML)
// 创建2D对象,并且隐藏
pointLabel = new CSS2DObject(labelDiv);
pointLabel.visible = false
pointLabel.name = 'label'
// 添加到车牌到车
car.add(pointLabel)
// 添加车到车组
carGroup.add(car)
})
// 变换时,更新车的位置
tween1.onUpdate(() => {
car.position.x = position.x
car.position.y = position.y
car.position.z = position.z
})
// 开启第二段动画
const tween2 = new TWEEN.Tween(position)
tween2.to(linePosition[ins.line].end, ins.endTimeStamp - ins.middleTimeStamp)
tween2.onUpdate(() => {
car.position.x = position.x
car.position.y = position.y
car.position.z = position.z
})
// 动画结束,移除车牌,移除车
tween2.onComplete(() => {
car.remove(pointLabel)
carGroup.remove(car)
})
// 链接两段动画,并开始
tween1.chain(tween2).start()
}
复制代码
创建Div
function createDiv(imgUrl: string, innerHTML: string) {
const labelDiv = document.createElement("div");
labelDiv.style.width = '163px'
labelDiv.style.height = '50px'
labelDiv.style.backgroundImage = imgUrl
labelDiv.style.textAlign = 'center'
labelDiv.style.verticalAlign = 'center'
labelDiv.style.color = '#ffffff'
labelDiv.style.borderRadius = '25px'
const div = document.createElement('div')
div.innerHTML = innerHTML
div.style.fontSize = '14px'
div.style.width = '113px'
div.style.marginLeft = '40px'
div.style.height = '25px'
div.style.lineHeight = '50px'
labelDiv.appendChild(div)
return labelDiv
}
复制代码
事件监听---点击
function onWindowMousedown(event: MouseEvent) {
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
pointer.y = - (event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(pointer, camera)
const intersect = raycaster.intersectObjects(carGroup.children) as any
if (intersect.length > 0) {
const flag = intersect[0].object.parent.getObjectByName('label').visible
intersect[0].object.parent.getObjectByName('label').visible = !flag;
}
}
复制代码
清除缓存,清理页面
function clear() {
// 移除事件监听
window.removeEventListener('resize', onWindowResize)
window.removeEventListener('mousedown', onWindowMousedown)
// 移除2D渲染器创建的div
document.body.removeChild(labelRenderer.domElement)
// 释放 GPU
scene.traverse((child: any) => {
if (child.isMesh) {
if (child.geometry) child.geometry.dispose();
if (child.material) child.material.dispose();
child.clear();
}
});
// 清除定时器
clearInterval(timer)
// 移除动画
TWEEN && TWEEN.removeAll()
// 场景清理
scene.clear();
// WebGLRenderer 释放 GPU 资源
renderer.dispose();
renderer.forceContextLoss();
THREE.Cache.clear();
}
复制代码
getDate.ts
简单的时间格式化
export const formatTime = (time: string | number | Date, fmt: string): string => {
if (!time) return ''
const date = new Date(time)
const o: any = {
Y: date.getFullYear(),
M: date.getMonth() + 1,
D: date.getDate(),
h: date.getHours(),
m: date.getMinutes(),
s: date.getSeconds()
}
for (let k in o) {
const t = o[k] < 10 ? '0' + o[k] : o[k]
fmt = fmt.replace(k, t)
}
return fmt
}
复制代码
websocket.ts
import { onBeforeMount, onBeforeUnmount } from 'vue';
import { getCar } from '../utils/three_home'
let ws: WebSocket
export function createWS() {
onBeforeMount(() => {
init()
})
onBeforeUnmount(() => {
ws.removeEventListener('message', handleMessage)
})
}
function init() {
ws = new WebSocket('ws://127.0.0.1:5555')
ws.addEventListener('open', handleOpen, false)
ws.addEventListener('close', handleClose, false)
ws.addEventListener('error', handleError, false)
ws.addEventListener('message', handleMessage, false)
return ws
}
function handleOpen(e: Event) {
console.log('websocket open');
}
function handleClose(e: Event) {
console.log(e);
}
function handleError(e: Event) {
console.log(e);
}
function handleMessage(e: MessageEvent) {
let data = JSON.parse(e.data)
getCar(data)
}
复制代码
服务端实现
websocket.js
// CommonJS 引入 ws
const ws = require('ws');
((ws) => {
// 创建服务
const server = new ws.Server({
port: 5555
});
// 初始化服务
const init = () => {
// 绑定事件
bindEvent()
// 发送消息到客户端
setInterval(() => {
server.clients.forEach(c => {
c.send(JSON.stringify(createData()));
})
}, 1000 + Math.random() * 1000)
}
function bindEvent(event) {
server.on('open', handleOpen);
server.on('close', handleClose);
server.on('error', handleError);
server.on('connection', handleConnection);
}
function handleOpen() {
console.log('WebSocket open');
}
function handleClose() {
console.log('WebSocket close');
}
function handleError() {
console.log('WebSocket error');
}
function handleConnection(ws) {
console.log('WebSocket connection');
}
init()
})(ws)
// 车牌数据
const licenceList = [
'长相思兮长相忆',
'短相思兮无穷极',
'相思相见知何日',
'此时此夜难为情',
'何处春江无月明',
'滟滟随波千万里',
'金风玉露一相逢',
'便胜却人间无数'
]
// 车牌颜色
const colorList = ['蓝', '绿', '黄', '蓝', '绿', '蓝', '绿']
// 车辆类型
const typeList = ['car1', 'car2', 'car3', 'car1', 'car1', 'car1', 'car2']
// 随机创建数据
function createData() {
// 车辆经过第一个节点的时间
const beginTimeStamp = new Date().getTime()
// 车辆经过第二个节点的时间
const middleTimeStamp = beginTimeStamp + 5000 + Math.random() * 2000
// 车辆经过第三个节点的时间
const endTimeStamp = middleTimeStamp + 5000 + Math.random() * 2000
const licence = licenceList[parseInt(Math.random() * 8)]
const color = colorList[parseInt(Math.random() * 7)]
const type = typeList[parseInt(Math.random() * 7)]
// 车道
const line = parseInt(Math.random() * 2)
const res = {
beginTimeStamp: beginTimeStamp,
middleTimeStamp: middleTimeStamp,
endTimeStamp: endTimeStamp,
licence: licence,
color: color,
type: type,
line: line
}
return res
}
复制代码
最后,就实现了
「喜欢这篇文章,您的关注和赞赏是给作者最好的鼓励」
关注作者
【版权声明】本文为墨天轮用户原创内容,转载时必须标注文章的来源(墨天轮),文章链接,文章作者等基本信息,否则作者和墨天轮有权追究责任。如果您发现墨天轮中有涉嫌抄袭或者侵权的内容,欢迎发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。




