# lab3 **Repository Path**: yessssa/lab3 ## Basic Information - **Project Name**: lab3 - **Description**: Three.js and socket.io - **Primary Language**: JavaScript - **License**: Not specified - **Default Branch**: main - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 1 - **Forks**: 0 - **Created**: 2021-11-07 - **Last Updated**: 2025-01-07 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # Lab 3:Three.js 与 socket.io ## 概述 Lab 3 的主要内容包括: - 使用 three.js 构建漫游场景 - 使用 socket.io 技术使web3d场景允许多人加入,并且行为共享。 - 使用 three.js 导入3D模型 ## Part 1: Three.js ### 前言 three.js 是一个 WebGL 库,对 WebGL API 进行了很好的封装。它库函数丰富,上手容易,非常适合 WebGL 开发。 > three.js 的github地址: > > three.js 的官方网址: https://threejs.org > > 首页左侧的 documentation 中是 three.js 的官方文档。 > > 文档下方的 examples 中有许多经典的例子。 > > 同学们学习 three.js 和开发 PJ 可以参考这两个。 ### 准备工作 有两种方法使用three.js * [下载](https://github.com/mrdoob/three.js/blob/dev/build/three.min.js) three.min.js并将他包含在你使用的html文件中。 ```javascript ``` * 使用npm安装three.js的 [module](https://threejs.org/docs/index.html#manual/en/buildTools/Testing-with-NPM) 并导入到你的项目中 * npm 安装three模块 ```shell npm i --save three ``` * three模块的导入 ```javascript // es6 style (recommended) import * as THREE from 'three' ``` 接下来我们以第一种方法来构建一个web3d场景 ### 一、场景(Scene) 首先,创建如下 HTML 文件(即 index.html)。 ```html My first three.js app ``` 接下来,在` ``` #### 1. 相机控制状态的进入和退出——鼠标点击 当我们想要控制相机时,我们调用dom元素的requestPointerLock方法进行鼠标光标的锁定,并为document的pointerlockchange事件做监听,来进入和退出控制状态。为pointerlockerror事件做监听,来判断浏览器能否使用该API > 注:有关pointer lock相关的api,可以参考[这篇文档](https://developer.mozilla.org/en-US/docs/Web/API/Pointer_Lock_API) 进行学习 ```javascript class FirstPersonControls { constructor(domElement) { this.domElement = domElement || document.body; this.isLocked = false; } onPointerlockChange() { this.isLocked = document.pointerLockElement === this.domElement; } onPointerlockError() { console.error( 'THREE.PointerLockControls: Unable to use Pointer Lock API' ); } connect() { this.domElement.addEventListener('click', this.domElement.requestPointerLock); // 思考函数后面为什么要加bind(this) document.addEventListener( 'pointerlockchange', this.onPointerlockChange.bind(this), false ); document.addEventListener( 'pointerlockerror', this.onPointerlockError.bind(this), false ); } } ``` 并在index.html中加入以下部分 ```javascript const fpc = new FirstPersonControls() fpc.connect() ``` 可以看到,进入场景点击鼠标左键时,鼠标光标消失,按下ESC后,鼠标光标恢复 ![pointerlock](./screenshot/pointerlock.png) #### 2. 控制相机的旋转——鼠标移动 接下来,我们在锁定状态下,通过鼠标移动来控制相机的旋转。 * 为control添加camera * 将camera封装进pitchObject,再将pitchObject封装进yawObject(请同学们思考为什么要这么做) * 将yawObject添加到场景中 * 鼠标移动触发相关对象的旋转 ```javascript class FirstPersonControls { constructor(camera, domElement) { this.domElement = domElement || document.body; this.isLocked = false; this.camera = camera; // 初始化camera, 将camera放在pitchObject正中央 camera.rotation.set(0, 0, 0); camera.position.set(0, 0, 0); // 将camera添加到pitchObject, 使camera沿水平轴做旋转, 并提升pitchObject的相对高度 this.pitchObject = new THREE.Object3D(); this.pitchObject.add(camera); this.pitchObject.position.y = 10; // 将pitObject添加到yawObject, 使camera沿竖直轴旋转 this.yawObject = new THREE.Object3D(); this.yawObject.add(this.pitchObject); } onPointerlockChange() { console.log(this.domElement); this.isLocked = document.pointerLockElement === this.domElement; } onPointerlockError() { console.error( 'THREE.PointerLockControls: Unable to use Pointer Lock API' ); } onMouseMove(event) { if (this.isLocked) { let movementX = event.movementX || event.mozMovementX || event.webkitMovementX || 0; let movementY = event.movementY || event.mozMovementY || event.webkitMovementY || 0; this.yawObject.rotation.y -= movementX * 0.002; this.pitchObject.rotation.x -= movementY * 0.002; // 这一步的目的是什么 this.pitchObject.rotation.x = Math.max( - Math.PI / 2, Math.min( Math.PI / 2, this.pitchObject.rotation.x ) ); } } connect() { this.domElement.addEventListener('click', this.domElement.requestPointerLock); // 在函数后面添加bind(this)的目的是什么 document.addEventListener('pointerlockchange', this.onPointerlockChange.bind(this), false ); document.addEventListener('pointerlockerror', this.onPointerlockError.bind(this), false ); document.addEventListener('mousemove', this.onMouseMove.bind(this), false); } } ``` 我们在index.html里面修改fpc的构造并添加下列语句 ```javascript // 修改fpc的构造,传入参数camera const fpc = new FirstPersonControls(camera); fpc.connect(); // 向场景添加用于控制相机的Object scene.add(fpc.yawObject); ``` #### 3. 控制相机的移动——键盘控制 接下来,我们在锁定状态下,通过键位操作来控制相机的平移。 - 在FirstPersonControls类中定义onKeyUp和onKeyDown方法,分别绑定keydown和keyup事件 - 在FirstPersonContrls类中定义定义update方法,在每次render时调用该方法,传入两次render的时间间隔,来完成相机的移动。 ```javascript // W S A D 的keycode const KEY_W = 87; const KEY_S = 83; const KEY_A = 65; const KEY_D = 68; class FirstPersonControls { constructor(camera, domElement) { // ... // 初始化移动状态 this.moveForward = false; this.moveBackward = false; this.moveLeft = false; this.moveRight = false; } // ... onKeyDown(event) { switch (event.keyCode) { case KEY_W: this.moveForward = true; break; case KEY_A: this.moveLeft = true; break; case KEY_S: this.moveBackward = true; break; case KEY_D: this.moveRight = true; break; } } onKeyUp(event) { switch (event.keyCode) { case KEY_W: this.moveForward = false; break; case KEY_A: this.moveLeft = false; break; case KEY_S: this.moveBackward = false; break; case KEY_D: this.moveRight = false; break; } } update(delta) { // 移动速度 const moveSpeed = 100; // 确定移动方向 let direction = new THREE.Vector3(); direction.x = Number(this.moveRight) - Number(this.moveLeft); direction.z = Number(this.moveBackward) - Number(this.moveForward); direction.y = 0; // 移动方向向量归一化,使得实际移动的速度大小不受方向影响 if (direction.x !== 0 || direction.z !== 0) { direction.normalize(); } // 移动距离等于速度乘上间隔时间delta if (this.moveForward || this.moveBackward) { this.yawObject.translateZ(moveSpeed * direction.z * delta); } if (this.moveLeft || this.moveRight) { this.yawObject.translateX(moveSpeed * direction.x * delta); } } connect() { // ... document.addEventListener('keydown', this.onKeyDown.bind(this), false); document.addEventListener('keyup', this.onKeyUp.bind(this), false); } } ``` > 注:这里我们只需要w, s, a, d 四个键位,每个键位的KeyCode信息可以在 中查到。 * 修改index.html中的 render部分 ```javascript let clock = new THREE.Clock(); function render() { fpc.update(clock.getDelta()); requestAnimationFrame(render); renderer.render(scene, camera); } ``` > 注:这里我们通过[THREE.Clock对象](https://threejs.org/docs/index.html#api/en/core/Clock) 来计算delta,还有很多其他的方法,请同学们自行研究 #### 4. 碰撞检测(可选) 碰撞检测包括的几种情况: * 底部碰撞,这一种碰撞常常在重力场景会遇到。 * 周围碰撞,这一种碰撞一般在移动时会发生,周围碰撞又分为 * 与场景碰撞 * 与其他玩家碰撞(多人vr交互场景中出现) * 顶部碰撞,这种碰撞一般在跳跃时发生。 碰撞检测可以有以下几种思路实现 * 通过[Raycaster](https://threejs.org/docs/index.html#api/en/core/Raycaster) ,向模型顶点发出规定长度射线来判断相交并做相应的处理。 * 通过引入物理引擎,如[physijs](http://chandlerprall.github.io/Physijs/) 。threejs的[car_demo](https://threejs.org/examples/#webgl_materials_cars) 就是用physijs实现的 同学们可以根据自己project的需要,来学习本部分,本次lab对碰撞检测不作要求。 ### 八、实现"响应式照相机" 相机在刚构建的时候,我们通过窗口的宽高比来设置相机的aspect(视锥宽高比),当浏览器窗口宽高发生变化时,我们希望相机的aspect能随着浏览器窗口的变化跟着改变,从而实现"响应式照相机",同时我们希望render也能响应窗口变化 在index.html中添加如下代码 ```javascript window.addEventListener("resize", onWindowResize); function onWindowResize() { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); } ``` 再次刷新页面,拉伸窗口我们能看到camera和renderer都有相应的变化。 至此,一个简单的漫游场景就实现了。推荐同学们以 three.js 官网为参考,首先浏览官方 examples ,查看 examples 的源代码,碰到问题时查阅 documentation。 > 相关链接: > > examples: https://threejs.org/examples/ > > documentation: https://threejs.org/docs/index.html ## Part 2: WebSocket ### 前言 #### 服务器推送技术的发展 http协议的最大缺陷在于,通信只能由客户端发起,服务器无法推送消息。于是产生了各种各样的推送技术。 * HTTP Polling 这种方式下,client 每隔一段时间都会向 server 发送 http 请求,服务器收到请求后,将最新的数据发回给 client。 ![http-polling](./screenshot/http-polling.png) * HTTP Long-polling client 向 server 发出请求,server 接收到请求后,server 并不一定立即发送回应给 client,而是看数据是否更新,如果数据已经更新了的话,那就立即将数据返回给 client;但如果数据没有更新,那就把这个请求保持住,等待有新的数据到来时,才将数据返回给 client。 ![http-polling](./screenshot/http-long-polling.png) * HTTP Streaming 流技术基于 Iframe。Iframe 是 HTML 标记,这个标记的 src 属性会保持对指定 server 的长连接请求,server 就可以不断地向 client 返回数据。 ![http-polling](./screenshot/http-streaming.png) * Web socket 前面的技术都只考虑如何让 server 尽快 '回复' client 的请求,为了彻底解决 server 主动向 client 发送数据的问题,W3C 在 HTML5 中提供了一种 client 与 server 间如何进行全双工通讯的网络技术 WebSocket。WebSocket 是一个全新的、独立的协议,基于 TCP 协议,与 HTTP 协议兼容却不会融入 HTTP 协议,仅仅作为 HTML5 的一部分。 与http的关系: * 都是应用层协议,基于TCP * websocket 在建立连接时需要借助http协议 ![websocket](./screenshot/websocket.png) > 注:以上内容参考自[知乎专栏](https://zhuanlan.zhihu.com/p/23467317) > > 更多的示例可以参考以下网站 > > > > 关于demo可以参考socket.io框架下的一个聊天室 > > ### Socket.io socket.io 主要使用 websocket 协议 socket.io 是一个面向实时web应用的javascript库,他有两个部分:在浏览器中运行的客户端库,和一个面向Node.js的服务端库。两者有着几乎一样的API。像Node.js一样,它也是事件驱动的。 * 面向nodejs服务器的[socket.io](https://github.com/socketio/socket.io) * 运行在浏览器端的[socket.io-client](https://github.com/socketio/socket.io-client) 接下来的实验中,我们将分别使用客户端和服务端的socket.io库来完成 ### 前提条件 接下来的工作,我们会构造一个多人交互的web3D场景,这需要一些前提条件 * 完成Part1的部分,实现一个离线的漫游场景,还未完成的同学建议先完成Part1部分。 * 了解websocket协议,观察一些诸如聊天室的demo的工作原理,了解使用websocket能做什么。 * [socket.io](https://socket.io/) 作为一个使用websocket协议的javascript库,了解socket.io在应用中是如何在客户端与服务端建立通信的。 ### 准备工作 ##### 服务端 我们采用nodejs服务器。 * 安装nodejs, npm工具,有以下两种安装方式 * 前往[官网](https://nodejs.org)下载安装 * 使用[nvm](https://github.com/creationix/nvm) 版本管理工具安装 > 注:安装完成后检查nodejs和npm是否是最新版本 * 我们使用[express](https://expressjs.com)框架与[socket.io](https://socket.io)框架,使用npm安装express包与socket.io包 ```shell mkdir server cd server npm init -y npm install --save express npm install --save socket.io ``` * 在server目录下创建index.js, 加入如下内容 ```javascript var app = require('express')(); var http = require('http').createServer(app); var io = require('socket.io')(http,{ cors: { origin: "http://127.0.0.1:2233", methods: ["GET", "POST"] } }); app.get('/', function(req, res){ res.send('

Hello world

'); }); io.on('connection', function (socket) { console.log('client '+ socket.id + ' connected'); socket.on('disconnect', function () { console.log('client ' + socket.id + ' disconnected'); }) }); http.listen(3000, function(){ console.log('listening on *:3000'); }); ``` * 在package.json的scripts项中加入一行"start",表示start 这条script对应 “node index.js” ```json "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "start": "node index.js" }, ``` * 运行服务器 ```shell npm start ``` 当你看到以下内容时,说明服务器已经成功运行在3000端口上 ``` > node index.js listening on *:3000 ``` 向localhost:3000发送get请求,可以看到以下内容 ![getlocalhost3000](./screenshot/getlocalhost3000.png) ##### 客户端 有两种方法使用面向客户端的socket.io - 下载[socket.io.js]()并将他包含在你使用的html文件中。 ```javascript ``` - 使用npm安装socket.io-client的module并导入到你的项目中 - npm 安装socket.io-client模块 ```shell npm i --save socket.io-client ``` - 模块的导入 ```javascript // es6 style (recommended) import io from 'socket.io-client' ``` 接下来我们采用第一种方法来构建客户端 在index.html中添加下面的语句 ```javascript const socket = io('ws://localhost:3000'); ``` 此时客户端的基本准备工作就完成了, 此时我们进入或离开客户端的3D场景时,可以看到服务端的控制台上会有相应的提示信息,说明连接已经成功 ```shell > node index.js listening on *:3000 client dfHU9jpSSl6d4DEgAAAB connected client dfHU9jpSSl6d4DEgAAAB disconnected ``` ### 通信架构 建立连接过后,我们要考虑客户端与服务端之间进行怎样的通信,主要分为以下三个部分 * 客户端不断上传自己的实时信息(位置信息与旋转信息) 在index.html中的renderer函数中加入以下代码,向服务端上传实时信息。 ```javascript function render() { fpc.update(clock.getDelta()); socket.emit('player', {position: fpc.yawObject.position, rotation: fpc.yawObject.rotation}); requestAnimationFrame(render); renderer.render(scene, camera); } ``` * 服务端接收每个客户端的实时信息(位置信息与旋转信息),并广播给其他客户端。当有某个客户端断开连接的时候,也要做一次广播。 ```javascript io.on('connection', function (socket) { console.log('client '+ socket.id + ' connected'); socket.on('player', function (data) { data.socketid = socket.id; socket.broadcast.emit('player', data); }); socket.on('disconnect', function () { console.log('client ' + socket.id + ' disconnected'); socket.broadcast.emit('offline', {socketid: socket.id}); }) }); ``` * 客户端收到服务端传来的其他客户端的实时信息,并在自己的场景中更新。 首先要在index.html中建立一个新的Map(该Map将其他客户端的socket.id映射到他们的模型上),来判断服务器发来的需要更新位置信息的客户端是否在场景中,如果没有在场景中,则需要为该客户新建一个模型,并把他加入到Map中,如果已经在场景中了,那只需要更新用户对应的模型的位置和旋转信息即可。 首先,我们下载[GLTFLoader.js](https://github.com/mrdoob/three.js/blob/master/examples/js/loaders/GLTFLoader.js) 到js文件夹里,并在index.html导入 ```html ``` 并在index.html中添加响应信息 ```javascript let playerMap = new Map(); socket.on('player', data => { if (playerMap.has(data.socketid)) { let model = playerMap.get(data.socketid); model.position.set(data.position.x, data.position.y, data.position.z); model.rotation.set(data.rotation._x, data.rotation._y + Math.PI / 2, data.rotation._z); } else { socket.emit('player', {position: fpc.yawObject.position, rotation: fpc.yawObject.rotation}); const loader = new THREE.GLTFLoader(); loader.load("./assets/models/duck.glb", (mesh) => { //如果这个判断注释掉会怎么样,为什么 if(!playerMap.has(data.socketid)) { mesh.scene.scale.set(10, 10, 10); scene.add(mesh.scene); playerMap.set(data.socketid, mesh.scene); let model = playerMap.get(data.socketid); } }); } }); socket.on('offline', data => { if (playerMap.has(data.socketid)) { scene.remove(playerMap.get(data.socketid)); playerMap.delete(data.socketid) } }); ``` 代码中标定的判断如果注释掉怎么样?并说出原因。此题作为加分题。 * 重启node服务端,打开两个浏览器窗口,我们可以看到每位玩家的实时信息都会在其他玩家的浏览器中以"duck"这个模型为载体展现出来。 ![muti-player](./screenshot/muti-player.png) > 思考:如果把第四部分所添加的光照去掉,整个场景会变成什么样子? 至此,一个简单的muti-player的web3d虚拟环境就搭建出来了,有兴趣的同学可以尝试着给pj添加语义化的功能,为不同的虚拟环境创建"房间",等等。 ## Part 3. 提交 截止时间:2021/6/14 23:59:59 提交方式:超星学习通(提交到如下图中)
![commit](./commit.png) 加分题:1、碰撞检测 2、通信架构部分客户端注释选择分支的意义 3、其他你认为有价值的部分。