2.5D(伪3D)站点可视化第一弹
楔子
最近要做一个基站站点的可视化呈现项目。 我们首先尝试的是三维的可视化技术来程序,但是客户反馈的情况是他们的客户端电脑比较差,性能效率都会不好,甚至有的还是云主机。 因此我们先做了一个性能比较极致的3Ddemo,如下图所示:
为了能够尽可能的性能最优,所以想了各种性能优化手段。当然效果上也会有折扣,这个demo与我们本身的一些产品比如3D机房等相比较,效果上面肯定有了很大的差距。不过性能方面还是很不错的。
然而,很不幸,客户在拿到demo测试之后,不满意...。性能还算凑合,但他们还觉得效果不够酷。
配置很低,又要性能高、又要效果炫。这只能化为一句话:
似乎陷入了绝境...
然而 绝处往往逢生,绝处往往有新的希望、新的机会。
2.5D的思想火花
突然想到的是2.5D,这是一种伪3D效果,但是只能体现一个镜头角度的显示效果,不能实现镜头的旋转效果。
其实在很早的时候,我们就有一些2.5D的雏形的东西,比如分层拓扑图和2.5D节点。分层拓扑图甚至可以追溯到Java时代。如下图所示:
把之前的2.5D源代码拿过来读一遍。读了之后,总的思路:主要通过拼凑三个平行四边形来模拟这种3D的效果,技术没有体系。
这种思路对于对象的位置定位和对齐会比较难,开发难度本身也比较大,另外要实现一些好的效果,难度也比较大,要知道客户对于效果的要求并不低。
因此需要想出新的技术思路,最好是有成体系的思路,要摆脱之前的技术思路。当然并不容易,当时我并没有什么好的思路,有很多疑惑,有很多迷茫。之后的很多天里面,都是这种状态。
事情的转机在一次出差。
在拜访一个大客户回酒店的路上,我走在马路上,我的脑中突然蹦出一个想法,为什么不借助3D的思路和部分算法呢,2.5D要呈现的不就是3D的效果吗?所谓2.5D,顾名思义,就是取几勺2D技术,再取几勺3D的技术,一起放到锅里炒一炒,为啥要局限在2D的技术。
我本身研究3D技术很多年,对于3D的相关技术也算是很熟练,突然,似乎所有的事情的想通了,一套成体系的2.5D技术开始在心中生根,发芽,生长。
我的内心很欣喜。(但是表面很平静)
这个事情告诉我们一个道理,弄不懂的问题,不要死抠,多出去走走,说不定就想通了。😄
接下来,我自信满满的和客户沟通,开始着手写相关的技术验证demo,其中涉及到一些技术会在后面说明。demo最终得到了客户的认可,最终我们也拿下了这个项目。
而于我,自己创造了一套2.5D相关的技术体系,也算是一个小小的成就吧。
这是一次创作,而创作是让人愉悦的事情。
2.5D技术概述
所谓的2.5D,就是通过2D绘制技术,实现3D的渲染效果。而这其中,势必需要用到一部分3D的技术:
- 三维空间的定义
- 模型的定义(使用三维空间坐标定义模型)
- 投影算法 把三维空间的坐标点通过投影算法,转换为二维空间的坐标。
三维空间定义
为了能够实现2.5D的效果,我们需要把原来的平面二维空间延伸到三维立体空间。三维立体空间中存在着X、Y、Z三个坐标轴,比原来的二维空间多出了一个Z坐标轴。
当然,三维空间定义是为了模型定义、模型位置定位和后续的投影算法。最终的绘制还是会回到二维空间进行。
模型定义
在真正的三维中,需要通过obj等模型文件来定义模型。 在2.5D中,只需要定义一个立方体的模型即可。 前面说过,2.5D只是呈现了三维对象的某个角度的一个面,因此其模型只需要这个面的一张图片即可,图片就是模型。
之所以要定义一个立方体的模型,是为了图片能够摆在合适的位置,以及约束合适的大小和长宽比。 这对于模型的摆放和对齐有很重要的意义。立方体在这里就类似真实模型的包围体。
通过指定宽、高、深等属性,便可以定义一个立方体。代码如下所示:
setSize3: function(w, h, d) {
var oldValue = {
w: this._width3,
h: this._height3,
d: this._depth3,
};
this._width3 = w;
this._height3 = h;
this._depth3 = d;
this.firePropertyChange('size3', oldValue, { w: w, h: h, d: d });
},
同时可以指定立方体的三维坐标位置,代码如下:
setPosition: function(x, y, z) {
var oldValue = this.getPosition();
this._position = {
x: x,
y: y,
z: z,
};
this.firePropertyChange('position', oldValue, this._position);
},
投影算法。
投影算法是三维图形学中很重要的一环。 投影算法主要有透视投影算法和平行投影算法。 2.5D中需要使用的是平行投影(也只能使用平行投影算法)
投影算法算是比较关键的一步。
要定义投影算法,我们首先要模拟一个平行镜头,通过平行镜头定义镜头的位置,角度等,并由这些参数定义出一个投影的矩阵:
/**
* 计算变换矩阵,变换矩阵由镜头参数决定
*/
calMVMatrix: function() {
var angle = this.getAngle3(),
vAngle = this.getVAngle3(),
radius = this.getRadius3(),
viewMatrix = mat4.create(),
projectMatrix = mat4.create(),
mvMatrix = mat4.create(),
winWidth = 1,
winHeight = 1;
mat4.lookAt(
viewMatrix,
[
radius * Math.cos(vAngle) * Math.sin(angle),
-radius * Math.sin(vAngle),
radius * Math.cos(vAngle) * Math.cos(angle),
],
[0, 0, 0],
[0, 1, 0]
);
mat4.ortho(
projectMatrix,
-winWidth / 2,
winWidth / 2,
-winHeight / 2,
winHeight / 2,
0.1,
1000
);
mat4.multiply(mvMatrix, projectMatrix, viewMatrix);
this.mvMatrix = mvMatrix;
},
上述代码中,定义投影矩阵使用了gl-matrix.js这个包。
在定义了投影矩阵之后,便可以通过投影算法计算出立方体上面每个顶点在平面坐标上的位置:
/**
* 布局,前面四个点 p1 - p4, 后面 四个点p 5 - p8
*
* p8 p7
*
* p5 p6
*
* p4 p3
*
* p1 p2
*
*/
var points1 = [
{
x: -w3 / 2 + pos.x,
y: -h3 / 2 + pos.y,
z: d3 / 2 + pos.z,
}, // p1
{
x: w3 / 2 + pos.x,
y: -h3 / 2 + pos.y,
z: d3 / 2 + pos.z,
}, // p2
{
x: w3 / 2 + pos.x,
y: h3 / 2 + pos.y,
z: d3 / 2 + pos.z,
}, // p3
{
x: -w3 / 2 + pos.x,
y: h3 / 2 + pos.y,
z: d3 / 2 + pos.z,
}, // p4
{
x: -w3 / 2 + pos.x,
y: -h3 / 2 + pos.y,
z: -d3 / 2 + pos.z,
}, // p5
{
x: w3 / 2 + pos.x,
y: -h3 / 2 + pos.y,
z: -d3 / 2 + pos.z,
}, // p6
{
x: w3 / 2 + pos.x,
y: h3 / 2 + pos.y,
z: -d3 / 2 + pos.z,
}, // p7
{
x: -w3 / 2 + pos.x,
y: h3 / 2 + pos.y,
z: -d3 / 2 + pos.z,
}, // p8
];
var points = (this._points = []);
points1.forEach(function(point) {
var newPoint = self.getPositionByRotate(
point,
pos,
rotationX,
rotationY,
rotationZ
);
points.push({
x: newPoint[0],
y: newPoint[1],
z: newPoint[2],
});
});
var ps = (this._projectPoints = points.map(function(point) {
return self.getProjectionPoint(point);
}));
有了8个顶点的投影点之后,可以绘制边框效果、可以绘制颜色填充效果,也可以绘制图片填充的效果。
绘制边框效果
把几个面的点按照顺序组织起来,即可以绘制边框的效果。 如下代码所示:
drawPoints: function (ctx, points, close, dash, fill, borderColor, image) {
if (!points || points.length == 0) {
return;
}
ctx.beginPath();
ctx.strokeStyle = "black";
if (borderColor) {
ctx.strokeStyle = borderColor;
}
ctx.lineWidth = 1;
ctx.fillStyle = 'rgb(102,204,255)';
if (dash) {
ctx.setLineDash([4, 4]);
ctx.strokeStyle = 'rgba(0,0,0,0.5)';
} else {
ctx.setLineDash([1, 0]);
}
ctx.moveTo(points[0].x, points[0].y);
for (var i = 1; i < points.length; i++) {
var p = points[i];
ctx.lineTo(p.x, p.y);
}
if (close) {
ctx.lineTo(points[0].x, points[0].y);
}
ctx.closePath();
ctx.stroke();
}
最终的绘制效果如下图所示:
绘制颜色填充效果
要绘制填充颜色的立方体,只需要在上边的绘制中加上这行代码即可:
if (fill) {
ctx.fill();
// drawImageInPoints(ctx, image, points);
}
最终的绘制效果如下:
绘制图片
绘制图片的时候,并不需要每个面都去绘制图片,只需要把图片绘制到立方体投影的8个顶点所占据的区域里面,需要做到的是,其8个顶点的位置正好和图片的顶点重合,比如下图:
首先计算出投影顶点所占据的二维区域大小:
/**
* 根据points中的8个点,找出包裹8个点的最小rect
*
* @param {Array} points - 8个点的2d坐标
* @returns {Object} - rect
*/
getRect: function(points) {
var minX, minY, maxX, maxY;
points.forEach(function(point) {
if (minX == null) {
minX = maxX = point.x;
minY = maxY = point.y;
} else {
minX = Math.min(minX, point.x);
maxX = Math.max(maxX, point.x);
minY = Math.min(minY, point.y);
maxY = Math.max(maxY, point.y);
}
});
return {
x: minX,
y: minY,
width: maxX - minX,
height: this.getElement().getClient('reflect')
? (maxY - minY) * 2
: maxY - minY,
};
},
然后在该区域直接绘制图片:
ctx.drawImage(
image,
0,
image.height - 20,
image.width,
20,
rect.x,
rect.y + rect.height - 20,
rect.width,
20
);
最终绘制效果如下图:
搭建地面、墙面
有了立方体模型之后,便可以搭建地面 墙面场景效果,由于地面、墙面都可以使用立方体来组成。 因此可以很方便的搭建出来,只需要把相关的立方体模型设置好尺寸,添加到场景中即可:
var node1 = new twaver.Node2_5({
styles: {
'body.type': 'vector',
},
name: 'TWaver',
centerLocation: {
x: 300,
y: 200
},
width: 800 / 1,
height: 360 /.775,
});
node1.setImage(null);
node1.setPosition(00,0,100);
node1.setWidth3(1000);
node1.setHeight3(10);
node1.setDepth3(1200);
// node1.setStyle('top.image','image0'); // ToDo 定义样式规则
// node1.setStyle('top.image.rule','pattern');
// node1.setClient('receiveShadow',true);
box.add(node1);
var node1 = new twaver.Node2_5({
styles: {
'body.type': 'vector',
},
name: 'TWaver',
});
node1.setImage(null);
node1.setPosition(-250,155,-500);
node1.setWidth3(500);
node1.setHeight3(300);
node1.setDepth3(1);
box.add(node1);
var node1 = new twaver.Node2_5({
styles: {
'body.type': 'vector',
},
name: 'TWaver',
});
node1.setImage(null);
node1.setPosition(250,105,-500);
node1.setWidth3(500);
node1.setHeight3(200);
node1.setDepth3(1);
node1.setStyle('front.image','weilan'); // ToDo 定义样式规则
box.add(node1);
最终的显示效果如下:
对于地面的贴图和墙面的光照效果,会在后续讲解。
第一弹讲述到这里,先上一张整体的效果瞅瞅:
欢迎关注公众号“ITman彪叔”。彪叔,拥有10多年开发经验,现任公司系统架构师、技术总监、技术培训师、职业规划师。在计算机图形学、WebGL、前端可视化方面有深入研究。对程序员思维能力训练和培训、程序员职业规划有浓厚兴趣。