暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

使用 threejs 实现真实世界的简单模拟,不限于模型加载

原创 yk 2023-03-04
435

使用 threejs 实现真实世界简单模拟,不限于模型加载


效果

如下,分别是早上6点下午6点我写完文章时的截图

ff17b2495b2adc0c4fbd7d986370f4b.png bae3eec87cb73f6d236fe6288656317.png image.png

目录结构

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. 客户端功能

  1. 3D模型加载
  2. 2D内容创建
  3. 天空和水和太阳
  4. 根据真实时间实现的太阳实时变换
  5. 根据模拟数据模拟的车辆实时运动
  6. 3D场景的交互

2. 服务端功能

  1. 开启简易的 websocket 服务,为前端模拟数据

3. 使用到的技术

  1. vue
  2. vite
  3. typescript
  4. three.js
  5. 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进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论