在前一篇文章《Hightopo 使用心得(3)- 吸附与锚点》中,我们在结尾处提到过 HT
的 3D
场景。这里我们通过代码建立一个 3D
场景并添加一个 Obj
模型来介绍一下 HT for Web
在 3D
场景和模型加载方面的使用。
这是我们最终实现的效果:
3D坐标系
在搭建 3D
场景之前,先介绍一下基本的 3D
概念。
HT for Web
在 3D
场景中采用的是右手坐标系,遵循右手螺旋法则。也就是:x
轴正方向朝右,y
轴正方向朝上,z
轴正方向朝向屏幕外。
与 2D
坐标系(x, y)
相比,这里多了一条坐标轴,也就是高度轴。2/3D
坐标系具体对应关系如下:
2D | 3D | |
---|---|---|
坐标轴 | x | x |
y | z | |
y |
从图片和表格中可以看到,在右手坐标系下,2D
坐标系中的 x,y
平面,在 3D
中对应的是 x,z
平面,也就是地平面。而在 3D
中多出来的一条坐标轴是高度坐标轴,也就是 y
轴。
有了三条坐标轴后,显而易见,我们在配置节点(ht.Node
)属性时就不能使用原来的方法。为此,HT
为ht.Node
扩展了一些新的方法。其中比较常用的有:
3D位置函数
- 设置位置(坐标):
setPosition3d(x, y, z)|setPosition3d([x, y, z])
,可传入x, y, z
三个参数,或传入[x, y, z]
的数组 - 获取位置(坐标):
getPosition3d()
的新函数,返回[x, y, z]
数组值,即[getPosition().x, getElevation(), getPosition().y]
- 设置大小(尺寸):
setSize3d(x, y, z)|setSize3d([x, y, z])
,可传入x, y, z
三个参数,或传入[x, y, z]
的数组 - 获取大小(尺寸):
getSize3d()
的新函数,返回[x, y, z]
数组值,即[getWidth(), getTall(), getHeight()]
3D锚点
3D
中节点同样有锚点的概念,同样HT
为ht.Node
图元增加了以下新函数:
-设置 3D
锚点: setAnchor3d(x, y, z)|setAnchor3d([x, y, z])
,可传入x, y, z
三个参数,或传入[x, y, z]
的数组
-设置获取y
轴方向锚点: getAnchorElevation()|setAnchorElevation(elevation)
-获取 3D
锚点 getAnchor3d()
的新函数,返回[x, y, z]
数组值,即[getAnchor().x, getAnchorElevation(), getAnchor().y]
3D旋转函数
ht.Node
在2D
坐标系下由getRotation()
和setRotation(rotation)
函数控制旋转,该参数对应于3D
坐标系下沿y
轴的负旋转值。 同时3D
坐标系下增加了rotationX
和rotationZ
两个分别沿着x
轴和z
轴的新旋转变量,同时增加以下新函数:
setRotationY(y)
设置沿y
轴旋转弧度,相当于setRotation(-y)
getRotationY()
获取沿y
轴旋转弧度,相当于getRotation()
- 设置围绕三个坐标轴的旋转角度:
setRotation3d(x, y, z)|setRotation3d([x, y, z])
,可传入x, y, z
三个参数,或传入[x, y, z]
的数组 - 获取当前在三个坐标轴的旋转角度:
getRotation3d()
的新函数,返回[x, y, z]
数组值,即[getRotationX(), -getRotation(), getRotationZ()]
3D场景搭建 - ht.graph3d.Graph3dView
在前面文章的例子中,创建一张 2D
图纸,使用的是 new ht.graph.GraphView()
; 而在这里,创建一个 3D
场景,我们需要使用 new ht.graph3d.Graph3dView()
;
与HT
其他视图组件一样, ht.graph3d.Graph3dView
也是基于于统一的ht.DataModel
数据模型来驱动图形显示。熟悉了2D图纸的同学可能会发现,其在场景配置,节点配置上与 2D
相似。
使用下面的代码,我们创建和配置了一个 3D
场景,并获取了其对应的数据模型(dataModel
):
/*************** 创建一个3D场景,添加到body下,并配置各种属性 ******************/
const g3d = new ht.graph3d.Graph3dView();
g3d.addToDOM(); // 添加到DOM
g3d.setGridVisible(true); // 显示网格
g3d.setEye(2000, 1000, 0); // 设置相机位置
g3d.setCenter(0, 0, 0); // 设置中心点
g3d.setUp(0, 1, 0); // 设置相机角度;这里默认值就是 [0, 1, 0]
g3d.setRotatable(true); // 允许旋转,默认值:true
g3d.setZoomable(true); // 允许滚轮缩放,默认值:true
g3d.setPannable(true); // 允许平移,默认值:true
g3d.setEditable(true); // 允许在场景中对节点进行编辑
const dm = g3d.getDataModel(); // 获取场景的 DataModel,简写形式:g3d.dm()
dm.setBackground('white'); // 同 dm.setBackground('rgba(255, 255 255, 1)'); 默认为黑色
创建场景后,我们又让它显示了辅助网格。我们可以将这些网格理解成地平面。模型在网格上方就相当于在地面之上。反之就是在地面下方。
相机的up坐标
在上例中,比较特殊的一个操作是 g3d.setUp(0, 1, 0)
。这里是指设置相机的 up
坐标。在计算机图形学中,相机的 up
坐标通常是指相机坐标系中的一个向量,用于定义相机的上方向。
例如在一个场景中,在水平面上有一栋房子,如果相机的 up
坐标是 (0,1,0)
,则相机将看到一个朝上的房子。如果相机的 up
坐标是(1,0,0)
,则相机将看到一个朝右的房子和竖直的地面(整个视角都会旋转)。在 HT for Web
中,默认的相机 up
坐标是 [0, 1, 0]
,也就是我们会看到一个正常的朝上的房子。
OBJ模型
HT
的 3D
场景支持 FBX,OBJ,GLTF
等多种模型格式。这里我们选择比较通用的 OBJ
模型来进行举例。
要使用 OBJ
模型,首先需要在 index.html
中引入 ht-obj.js
插件:
<script src="../../lib/plugin/ht-obj.js"></script>
加载OBJ模型
通过使用 ht.Default.loadObj() 方法可以将 OBJ
模型加载到内存中。在执行loadObj()
时,需要配置 OBJ
路径,材质路径以及相关参数。其中参数 params
的详细说明可以从以下连接获取:
Global | HT for Web (hightopo.com)
它里面比较常用的几个参数有:
center | boolean | 模型是否居中,默认为false,设置为true则会移动模型位置使其内容居中 | |
---|---|---|---|
prefix | string | 图片路径前缀,即在map_kd值之前增加的前缀,如果是相对路径则以加载obj的html页面的路径为参考 | |
shape3d | string | 如果指定了shape3d名称,则HT将自动将加载解析后的所有材质模型构建成数组的方式,以该名称进行注册 | |
finishFunc | function | 用于加载后的回调处理 |
/**
* 加载 obj 模型
*
* @param {*} modelName
* @return {*}
*/
function loadObj(objPath, mtlPath, modelName) {
return new Promise((resolve, reject) => {
/**
* 模型参数,具体参数参考:https://www.hightopo.com/guide/doc/global.html#LoadObjParams
*/
const params = {
center: true,
prefix: 'obj/',
shape3d: modelName,
finishFunc: (modelMap, array, rawS3) => {
resolve({modelMap, array, rawS3});
},
};
// 加载模型
ht.Default.loadObj(objPath, mtlPath, params);
});
}
其中的 shape3d
参数是一个自定义字符串,可以将该字符串理解为我们为模型配置了一个名字。HT
在加载完 OBJ
模型后,它会把该模型存储到内存中。存储的方式就是通过 ht.Default.setShape3dModel(name, model)
方法。
在上面的代码中,我们为要加载的模型起了一个名字:modelName
。在想使用该模型的时候,再通过ht.Default.getShape3dModel(name)
方法便可把模型从内存中取出来。
将Obj模型添加到3D场景
在上面的 loadObj()
只是将 OBJ
模型添加到了内存中。我们还需要在之后将 OBJ
模型添加到场景中。
由于 loadObj()
方法为异步执行,因此其参数里面需要携带一个 finishFunc
作为回调参数。为了减少代码层级,我们将上面的方法封装成了Promise
性质。后面我们可以等待这个 Promise
完成后再执行添加动作。
const MODELS = {
// 直升机
HELICOPTER: {
name: 'helicopter',
obj: 'obj/helicopterhspt_1002_01.obj',
mtl: 'obj/helicopterhspt_1002_01.mtl',
},
// 螺旋桨
PROPELLER: {
name: 'propeller',
obj: 'obj/helicopterhspt_1002_02.obj',
mtl: 'obj/helicopterhspt_1002_02.mtl',
},
};
/**
* 加载模型;模型初始化;创建模型Node; 添加模型到3D场景中
*
* @return {*}
*/
async function createObj(name, obj, mtl) {
const objInfo = await loadObj(obj, mtl, name); // 加载计量表模型,此处为异步
// * @param {*} modelMap 调用ht.Default.parseObj解析后的返回值,若加载或解析失败则返回值为空
// * @param {*} array 所有材质模型组成的数组
// * @param {*} rawS3 包含所有模型的原始尺寸
const {modelMap, array, rawS3} = objInfo;
console.log('createObj: ', objInfo);
if (!modelMap) {
return;
}
// 创建 Node 用来存放该模型,后续对模型的操作通过该 Node 进行
const node = new ht.Node();
node.s({
'shape3d': name, // 对应ht.Default.getShape3dModel(name)注册的模型
'shape3d.scaleable': false
});
node.setSize3d(rawS3); // 存放模型在三个坐标轴方向上的大小。简写:node.s3()
node.setPosition3d(0, 0, 0); // 此处可以将其放到水平面上。简写:node.p3()
dm.add(node);
return node;
}
const helicopterNode = await createObj(MODELS.HELICOPTER.name, MODELS.HELICOPTER.obj, MODELS.HELICOPTER.mtl);
const propellerNode = await createObj(MODELS.PROPELLER.name, MODELS.PROPELLER.obj, MODELS.PROPELLER.mtl);
直升机模型分为两部分,分别是机体和螺旋桨。由于他们是两个模型,因此需要分别添加。
在 loadObj
结束后,HT
会将模型通过 ht.Default.setShape3dModel(name, model)
注册到内存中,之后会给 finishFunc
传递三个参数:modelMap, array, rawS3
。其解释参考上面代码注释。目前我们用到的只有 rawS3
参数,也就是模型尺寸(大小)。
有了模型和尺寸(大小),我们便可以创建 ht.Node
用来对模型进行管理。将模型添加到 3D
场景中进行管理的主要逻辑如下:
模型 -(绑定到)→ ht.Node -(添加到)→ dataModel -(绑定到)→ Graph3dView
这里面的一个关键步骤是设置 ht.Node
的 shape3d
属性。由于在 loadObj
的时候系统已经对模型进行注册,因此这里我们只需要通过将注册的模型名称赋值给 ht.Node
的 shape3d
属性,HT
便可自动匹配到内存中对应的 OBJ
模型。
需要注意的是:在加载了模型并将模型绑定到 ht.Node
后并不能使其在 3D
场景中显示。只有通过 dataModel.add(node)
将节点添加到 3D
场景对应的数据模型中时,HT
才会在场景中将模型渲染出来。
模型位置
在上图中我们可以发现,直升机和螺旋桨重合了,并且二者也不在地面上。这里我们详细解释一下。
仔细查看代码,在创建 ht.Node
时,我们执行了下面的操作:
node.setSize3d(rawS3); // 存放模型在三个坐标轴方向上的大小。简写:node.s3()
node.setPosition3d(0, 0, 0); // 此处可以将其放到水平面上。简写:node.p3()
这两行命令分别是设置节点的大小和位置。这里的节点尺寸采用的是模型尺寸。而位置默认放到的坐标系中心点。
由于在 3D
场景中,ht.Node
的默认锚点是 [0.5, 0.5, 0.5]
,也就是在模型的三维中心点。因此其位置坐标也要对应到其中心点。这样,模型就会有一半在网格上方,另一半在网格下方。
该如何将直升机放到地平面上呢?我们可以通过模型的高度来计算出对应的位置从而将模型放到地平面上。具体代码如下:
// 由于默认创建 Node 的时候,其锚点是在 [0.5, 0.5, 0.5],位置是在 [0, 0, 0]。导致模型并不在水平面以上。
let size3d = helicopterNode.getSize3d(); // 获取直升机模型的 [长,宽,高]
let height = size3d[1]; // 获取模型高度
helicopterNode.setPosition3d([0, height/2, 0]); // 将直升机放到地面上
而对于螺旋桨,情况又有些复杂。这里需要一些技巧才能将其配置到合适的位置。
我们通过手动调整螺旋桨来获取其应该摆放的位置和角度。这里就用到了g3d.setEditable(true)
功能。打开编辑功能后,选中模型,场景中会显示坐标轴,通过拖动不同的坐标轴我们可以对模型进行移动,旋转和缩放。
将螺旋桨移动到机体合适的位置后,在console
中通过 node.getPosition3d()
和 node.getRotation3d()
来获取螺旋桨当前的位置和角度:
然后配置到代码中。与此同时,我们通过 setHost()
将螺旋桨吸附到了直升机上。这样,后面直升机移动时会带着螺旋桨移动。使二者不会脱离。
propellerNode.setRotation3d([0.10506443461595279, 4.550746858974086, -0.007825951889059535]); // 让螺旋桨水平
propellerNode.setPosition3d([0, 215, -99.00152946490829]); // 将螺旋桨放到直升机上
propellerNode.setHost(helicopterNode); // 螺旋桨吸附到直升机上
直升机动画
在直升机和螺旋桨都加载完成后,我们现在就可以为其增加相应的动画。
这里的动画分为两部分:
1. 螺旋桨旋转
2. 直升机移动
/**
* 循环前进与后退
*
* @param {*} node
*/
function startAnim(node) {
const p1 = node.p3(); // 原始位置
const p2 = [p1[0], p1[1], p1[2] - 400]; // 目标位置,
const forwardParams = {
duration: 3 * 1000, // 动画帧数
easing: (t) => { return t; }, // 动画缓动函数,默认采用`ht.Default.animEasing`
finishFunc: () => {
ht.Default.startAnim(backwardParams);// 循环播放该动画
}, // 动画结束后调用的函数。
action: (v, t) => { // action函数必须提供,实现动画过程中的属性变化。
node.setPosition3d( // 此例子展示将节点`node`从位置`p1`动画到位置`p2`。
p1[0] + (p2[0] - p1[0]) * v,
p1[1] + (p2[1] - p1[1]) * v,
p1[2] + (p2[2] - p1[2]) * v,
);
}
};
const backwardParams = {
duration: 3 * 1000, // 动画帧数
easing: (t) => { return t; }, // 动画缓动函数,默认采用`ht.Default.animEasing`
finishFunc: () => {
ht.Default.startAnim(forwardParams);// 循环播放该动画
}, // 动画结束后调用的函数。
action: (v, t) => { // action函数必须提供,实现动画过程中的属性变化。
node.setPosition3d( // 此例子展示将节点`node`从位置`p1`动画到位置`p2`。
p2[0] + (p1[0] - p2[0]) * v,
p2[1] + (p1[1] - p2[1]) * v,
p2[2] + (p1[2] - p2[2]) * v,
);
}
};
ht.Default.startAnim(forwardParams);
}
/**
* 螺旋桨旋转动画
*
*/
function startPropellerAnim(node) {
setInterval(() => {
const r3 = node.getRotation3d();
node.setRotation3d([r3[0], r3[1] + 0.4, r3[2]]); // 绕 Y 轴旋转。单位:弧度
}, 20);
}
螺旋桨旋转动画比较简单。我们只需要让其绕着 y
轴转动就可以了。这里我们利用 setInterval()
起一个定时器,每隔 20
毫秒让其沿着 y
轴旋转 0.4°
。
关于直升机动画,我们为其找了两个点,让它在这两点之间来回移动。在动画的实现上,我们依然采用前几篇文章提到的 ht.Default.startAnim()
方法。具体实现见上面代码部分。
总结
这篇文章介绍了如何使用 HT for Web
的 Graph3dView
和 OBJ
模型来创建 3D
场景。里面介绍了 3D
的一些基本概念以及 3D
场景的基本搭建与配置。另外,除了 3D
场景,我这里还重点描述了如何加载 OBJ
文件,如何添加模型节点到 3D
场景中,以及如何为节点添加动画。希望这些基本知识能对大家有所帮助。