楔子
最近要做一個基站站點的可視化呈現項目。 我們首先嘗試的是三維的可視化技術來程序,但是客戶反饋的情況是他們的客戶端電腦比較差,性能效率都會不好,甚至有的還是雲主機。 因此我們先做了一個性能比較極致的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、前端可視化方面有深入研究。對程序員思維能力訓練和培訓、程序員職業規划有濃厚興趣。