本人曾于2017年撰写了《WebGL开发与应用》一书,比较全面的介绍了threejs引擎的基本框架(如坐标系、矩阵、场景、相机等)和编程基础(如材质、灯光、网格、法线、UV坐标、动画、模型分析、用户交互、场景控制等),当时,浏览器对WebGL的支持还不是很普及(当时大量的用户还在使用微软的IE,而微软对WebGL一直不太友好),因此国内从事threejs(https://threejs.org/)开发的人员很少,相关的书籍或资料也比较少。
如今,形式已经发生了很大的变化,浏览器方面,几乎没有人再使用IE(edge基本取代了IE),而主流的浏览器均已完全支持WebGL;脚本方面,从ECMAScript 6.0开始已经支持类(class)的概念,从而可以对JavaScript进行面向对象编程;引擎方面,threejs目前一枝独秀,在国内外都得到了广泛的应用,特别是在游戏开发、虚拟现实、建筑可视化、数据可视化等领域,许多知名公司和开发者都开始使用threejs来创建高质量的Web应用,这正是作者再次撰写此书的原因,当然,此书的目标不再是介绍threejs的编程基础,而是提供一个更直观的threejs开发框架。
Threejs的原生代码是面向过程的,并不适合用于开发业务逻辑复杂的大型应用程序,作者结合自己多年的编程经验,开发了一套基于threejs的面向对象开发框架-ThreeSim,该框架对大量的threejs底层操作进行了封装(比如三维场景的初始化、空间目标选取、模型导入与分析、动画生成等),这些底层代码对于每一个threejs应用程序来说都是差不多的,但却往往消耗我们很多的调试精力,有了这个框架,我们就可以忽略各种底层的实现细节(如矩阵变换、向量计算等),将更多的精力集中在具体的业务逻辑上,代码会变得直观很多、简单很多。
ThreeSim框架的核心由三个类组成:(1)ThreeBase:基类,处理异步执行问题;(2)ThreeApp:应用程序类;(3)ThreeObject:3D对象类。通常情况下,一个ThreeSim应用程序会由一个继承自ThreeApp的应用程序类和多个继承自ThreeObject的3D对象类组成,通过在应用程序中添加3D对象,构建完整的Web3D场景,在用户交互方面,可以通过对应用程序或3D对象进行鼠标或键盘事件编程来完成。
本书的所有代码基于以下规范编写:(1)基于ECMAScript 6.0规范;(2)基于threejs R163版本;(3)类名使用大驼峰命名法(Upper Camel Case,即每个单词的首字母都采用大写字母,例如ThreeApp、ThreeObject);(4)变量、函数名使用小驼峰命名法(Lower Camel Case,即第一个单词以小写字母开始,后续单词的首字母大写,例如userName、addObject())。
目录
12.处理鼠标移动事件(onDocumentMouseMove)
13.处理鼠标按下事件(onDocumentMouseDown)
14.处理鼠标松开事件(onDocumentMouseUp)
19.反向搜索ThreeObject对象(findObjectFromIntersected)
1.程序调试类(Logger)
如何将大量的数据高效的展示在页面上,这是一个很繁重且很重要的工作,通常我们可以使用innerHTML、innerText、textContent等属性向DOM元素中输出数据,典型的代码如下:
const myDiv = document.getElementById('myDiv');
let someText = "here is some text";
myDiv.innerText = someText;
这种原生的写法会有两个问题,一是程序繁琐,准备数据时需要时刻关心DOM元素的名称;二是只能显示基本的数据类型,比如数字、字符串等,如果要调试的变量是一个对象,JavaScript默认会显示为“[object Object]”,从而导致无法看到对象的具体内容。
在JavaScript编程实践当中,经常会遇到需要显示或者调试一个对象的具体内容的情况,如果仅仅是调试,那么可以通过console.log的方法向浏览器的控制台输出对象,控制台允许用户逐级展开对象,从而看到对象的详细内容,但如果需要在页面中显示该对象的具体内容,就需要通过递归的方式遍历对象,因为对象的层级深度是不确定的。
我们把这些数据展示的工作整合成一个类,代码如下。
class Logger {
constructor(elem) {
this.elem = elem;
this.lines = [];
}
//调试基本类型的变量
log() {
this.lines.push([...arguments].join(' '));
}
//调试对象变量
logObject(obj){
for (let [key, value] of Object.entries(obj)) {
this.log(key+":",value);
}
}
//递归调试对象的所有key和value
logObjectAll(obj,c){ //c表示层级缩进的字符
let chr = c || ' '; //默认使用全角空格缩进
for (let [key, value] of Object.entries(obj)) {
if (typeof value === 'object' && value !== null){
this.log(chr,key,":","[Object]");
this.logObjectAll(value,chr + chr); //进入下层循环
}
else
this.log(chr,key,":",value);
}
}
//向页面输出内容
render() {
this.elem.innerHTML = this.lines.join('<br />');
this.lines = [];
}
}
export {Logger};
下面的例子展示了如何利用Logger类进行程序调试。
let logger = new Logger(document.querySelector('#info'));
let obj={
a:{a1:'this is a1' , a2:{a21:'this is a21' , a22:'this is a22'}},
b:{b1:'this is b1' , b2:'this is b2'}
};
logger.logObjectAll(obj);
logger.render();
效果如图所示。
由于大多数的3D模型都包含层级关系,当我们在做模型分析的时候,经常需要一种能够快捷直观的查看模型层级关系的方法,Logger类可以很好的胜任这项工作。下图展示的是一只鹦鹉的GLB模型及其结构数据(mesh.userData),我们会在后续的章节中详细讲解具体的实现方法。
需要注意一点,模型文件可能很大(几何、材质、动画、矩阵等数据),递归输出的层级数据也就很多,向页面上输出这些数据时有可能导致页面崩溃(out of memory),编程时应注意这个问题。
2.异步处理类(ThreeBase)
利用threejs进行3D编程时,经常会遇到需要进行异步处理的情况,比如等待材质文件载入后才开始载入3D模型文件、等待3D模型载入后才开始做模型分析和渲染、等待前一个关键帧动画结束后再播放另一个关键帧动画等等,为保证这些异步的代码能够被直观的定义和有效的执行,我们创建一个基于消息订阅/发布模式的异步执行基类ThreeBase,并约定所有的threejs程序和对象类应从该类继承而来,这就可以简化后续的异步编程工作,提高代码的可读性。
class ThreeBase {
constructor() {
this.messageCenter = {};
}
subScribe(message, callback) {
let fc = this.messageCenter[message];
if(fc) return;
fc = callback;
this.messageCenter[message] = fc;
}
unSubScribe(message) {
let fc = this.messageCenter[message];
if(fc)
delete this.messageCenter[message];
}
publish(message) {
let fc = this.messageCenter[message];
if(fc) {
let args = Array.prototype.slice.call(arguments);
fc.apply(this, args);
}
}
}
export {ThreeBase}
ThreeBase类共定义了4个方法。
1.构造函数(constructor)
构造函数,该函数定义了一个消息中心,每条消息都是一个JSON对象。
2.订阅消息(subScribe)
订阅消息,该函数接受两个参数,消息本身、回调函数,作用在于向消息中心追加一个JSON消息,当该消息被发布时(一般会在subScribe之后,由publish函数发布),可以调用callback函数,比如:
let a1 = new ThreeBase();
a1.subScribe("Loading", handle);
a1.subScribe("Building", handle);
console.log(a1);
则消息中心(即messageCenter)为: Object { Loading: handle(), Building: handle() },如图所示。
3.取消订阅消息(unSubScribe)
取消订阅一个消息。
4.发布消息(publish)
发布一个消息。该函数的目的是为了立刻执行该消息所对应的回调函数。
请注意这里有一个JavaScript如何传递函数参数的问题,尽管此处的publish函数只定义了一个参数message,但实际执行时是没有这个限制的,可以传递多个参数,比如:
a1.publish("Loading" , 50);
这些参数被保存在一个特殊的对象中,即arguments,arguments参数在JavaScript中是一个特殊的对象,它包含了函数运行时传递的所有参数。arguments对象类似于数组,但它不是一个数组,可以通过arguments[n]来访问对应的单个参数的值,并且拥有一个length属性,表示参数的个数。与普通数组不同的是,arguments是一个对象,而不是数组,因此它不具有数组的许多方法,如push、pop等。
对于上面的例子,arguments对象为["Loading" , 50],即arguments [0]="Loading",arguments[1]=50。
该函数的关键语句fc.apply(this, args)则把这些实际的参数传递给了回调函数(比如上述例子中的handle),JavaScript约定apply函数的第二个参数必须是一个数组或类数组对象,因此下面的两行代码也可以直接写成这样:fc.apply(this, arguments);
let args = Array.prototype.slice.call(arguments);
fc.apply(this, args);
下面的例子完整地说明了如何使用该类。
let a1 = new ThreeBase();
a1.subScribe("Loading", handle);
a1.subScribe("Building", handle);
//回调函数
function handle(){
let t = document.querySelector('#container');
let v = [...arguments];
t.innerText = t.innerText + "\n" + v[0] + ":" + v[1];
}
setTimeout(()=>{a1.publish("Loading" , 0);},1000);
setTimeout(()=>{a1.publish("Loading" , 100);},2000);
setTimeout(()=>{a1.publish("Building" , 'step-1');},3000);
setTimeout(()=>{a1.publish("Building" , 'step-2');},4000);
这段代码每隔1秒钟输出一行文本,效果如图所示。
这段代码的回调函数中运用到了JavaScript中的三点运算符。
let v = [...arguments];
三点运算符是JavaScript的一个扩展运算法,常用于解构数组或形参,比如当执行下面的代码时,数组v的内容为["Loading" , 0],即v[0]="Loading",v[1]=0 。
setTimeout(()=>{a1.publish("Loading" , 0);},1000);
三点运算符的一个典型应用是数组的合并,比如:
const boys = ['Bob', 'Charlie'];
const girls = ['Alice', 'Diana'];
const all = [...boys, ...girls];
console.log(all); // ["Bob", "Charlie", "Alice", "Diana"]
在大型3D应用程序中,由于模型本身可能很大,下载模型需要的时间也会很长,为改善应用程序的用户体验,通常会在主界面中设计一个进度条,实时显示当前的下载进度,下面的代码是实现该功能的关键部分。
//载入模型时,订阅“loadding”消息
obj.subScribe("loadding", (...v)=>{
logger.log("Loadding:",v[1].toFixed(2) + '%');
logger.render();
});
//载入过程中,发布“loadding”消息
let onProgress = function (xhr) {
if (xhr.lengthComputable) {
let percentComplete = xhr.loaded / xhr.total * 100;
that.publish('loadding',percentComplete);
}
};
请注意在这个例子中,“loadd