奇技 · 指南
相信从事过数据可视化开发的你对大屏并不陌生,那么开发一个酷炫的大屏一定是很多数据可视化开发者想要做的事情。
我们使用three.js,大约一周的时间开发出了一个酷炫的数据可视化大屏:
1
前言
由于篇幅问题,整篇会分为两个部分,围绕以下几个核心分享:
【一】
地球的实现
地球可点击的交互逻辑
飞线的实现
【二】
平面地图的实现
柱体的实现
性能优化
地图相关问题
涉及到的知识点:
GLSL:着色器在各3D对象中的应用
THREE.ShaderMaterial:three.js与着色器的复合应用
THREE.Texture:贴图与着色器的复合应用
THREE.CubicBezierCurve3:三次三维空间贝塞尔曲线
THREE.CylinderGeometry:如何基于数据为圆柱几何体上色
使用的技术栈:
vue
webpack
three.js
antv
d3.js
2
酷炫的地球
在我们的大屏中,酷炫的地球作为颜值担当,有效的撑起了场面。
地球
地球使用THREE.ShaderMaterial
实现,它由多张贴图材质构成,而非使用多面模型。
他承载了球体本身
和点击交互
。
地球由五张贴图组成:
这张索引贴图为每个国家分配
1 - 255
之间不同的索引颜色。部分国家颜色只是看似相近,实际数值不同。非陆地部分的颜色为
0
。
他用于我们在做点击交互时识别点击位置的国家
和GLSL为选择的国家上色
。
在使用时需要注意:贴图不能出现模糊、羽化等现象,使用photoshop编辑时要使用铅笔笔触。否则会影响到片元着色器的计算。
他是一张
1 x 256
大小的索引贴图。初始状态下第1
个色值是#000000
,剩下2 - 256
是#FFFFFF
的。他需要随着交互动态变化,所有由canvas生成。
const lookupCanvas = document.createElement('canvas');
lookupCanvas.width = 256;
lookupCanvas.height = 1;
const lookupTexture = new THREE.Texture(lookupCanvas);
lookupTexture.magFilter = THREE.NearestFilter;
lookupTexture.minFilter = THREE.NearestFilter;
lookupTexture.needsUpdate = true;
下标为 0 的像素对应mapIndex大海的颜色 #000000
。
下标在 1 - 255 之间的像素与mapIndex不同国家的索引颜色对应。
在触发点击交互获取到对应国家所代表的颜色时,改变其在lookup贴图对应下标位置的颜色,这里我们定义为#CCCCCC
对应float 0.8
。
这样在片元着色器运行时我们可以区分国家
、海洋
和被选中的国家
来进行不同的渲染计算。
uniform sampler2D mapIndex;
uniform sampler2D lookup;
varying vec2 vUv;
void main() {
vec4 earthColor = vec4(0.0);
vec4 mapColor = texture2D(mapIndex, vUv);
float indexedColor = mapColor.x;
vec4 lookupColor = texture2D(lookup, vec2(indexedColor, 0.0)); // 使用mapIndex与lookup对应
if (lookupColor.x == 1.0) { // 国家 #FFFFFF
} else if (lookupColor.x == 0.0) { // 海洋 #000000
} else if (lookupColor.x == 0.8) { // 被选中的国家 #CCCCCC
}
gl_FragColor = earthColor;
}
这张贴图勾勒出了国家边界。
这张索引贴图不同于mapIndex,他可以进行模糊处理,并且要尽量保证#FFFFFF
颜色的线条不超过 1 像素。
我们可以在片元着色器计算时通过数值判断来控制边界粗细。
但颜色如果是#FFFFFF
,我们将只能控制这部分边界是显示还是不显示。
uniform sampler2D outline;
varying vec2 vUv;
void main() {
float outlineColor = texture2D(outline, vUv).x
if (lookupColor.x == 1.0) { // 国家 #FFFFFF
if (outlineColor > 0.3) { // 此处过滤数值越大 国界越细
} else { // 国家颜色
}
} else if (lookupColor.x == 0.0) { // 海洋 #000000
} else if (lookupColor.x == 0.8) { // 被选中的国家 #CCCCCC
if (outlineColor > 0.0) { // 0.0 代表显示当前国家区域内所有的边界
} else {
}
}
gl_FragColor = earthColor;
}
这张贴图仅仅是写了几个国家的名字。
使用时文字贴图会优先所有判断,从而显示在球体上。
需要注意的是:球体会按极坐标使用贴图,所以写在离南北极较近地方的文字要随着纬度拉的胖一些。
uniform sampler2D textTexture;
varying vec2 vUv;
void main() {
vec4 text = texture2D(textTexture, vUv);
if (lookupColor.x == 1.0) {
} else if (lookupColor.x == 0.0) {
} else if (lookupColor.x == 0.8) {
}
if (text.w > 0.3) { // 此处过滤数值越大 文字越细
earthColor = vec4(0.7, 0.7, 0.7, 1); // 文字颜色;覆盖前面的颜色计算
}
}
这张贴图描绘了海洋的深度。
在片元着色器计算时判断为海洋的位置将会使用海洋的贴图。
uniform sampler2D depthTexture;
varying vec2 vUv;
void main() {
vec4 depth = texture2D(depthTexture, vUv);
if (lookupColor.x == 0.0) { // 海洋 #000000
earthColor = vec4(mix(vec3(1.0), depth.xyz, 0.86), 1.0); // mix混合白色让海洋亮一些
}
gl_FragColor = earthColor;
}
除了贴图外,我们还要定义 5
个颜色与 1
个布尔状态。他们分别是:
surfaceColor: 正常陆地颜色
selectedColor: 选中国家后的陆地颜色
lineColor: 正常国界颜色
lineSelectedColor: 选中国家的国界颜色
u_lightColor: 常驻渐变色
flag: 正在进行点击交互
的标记
uniform sampler2D mapIndex;
uniform sampler2D lookup;
uniform sampler2D outline;
uniform sampler2D textTexture;
uniform sampler2D depthTexture;
uniform float outlineLevel;
uniform vec3 surfaceColor;
uniform vec3 lineColor;
uniform vec3 lineSelectedColor;
uniform vec3 selectedColor;
uniform vec3 u_lightColor;
uniform float flag;
vec3 u_lightDirection = vec3(0.0, 1.0, 0.0); //光的入射方向
varying vec3 vNormal;
varying vec2 vUv;
void main() {
vec4 mapColor = texture2D(mapIndex, vUv);
vec4 text = texture2D(textTexture, vUv);
vec4 depth = texture2D(depthTexture, vUv);
float indexedColor = mapColor.x;
vec4 lookupColor = texture2D(lookup, vec2(indexedColor, 0.0));
float outlineColor = texture2D(outline, vUv).x;
float diffuse = lookupColor.x + indexedColor + outlineColor;
vec4 earthColor = vec4(0.0);
if (flag == 1.0) {
if (lookupColor.x == 1.0) { // 国家 #FFFFFF
if (outlineColor > 0.3) { // 此处过滤数值越大 国界越细
earthColor = vec4(lineColor, 0.8); // 国界的颜色
} else {
earthColor = vec4(mix(surfaceColor, vec3(indexedColor), 0.0), 1.0); // 国家的颜色
}
} else if (lookupColor.x == 0.0) { // 海洋 #000000
earthColor = vec4(mix(vec3(1.0), depth.xyz, 0.86), 1.0); // mix混合白色让海洋亮一些
vec3 faceNormal = normalize(vNormal); // 表面的法向量
float nDotL = max(dot(u_lightDirection, faceNormal), 0.0); // 获取入射光线与法向量的夹角
vec4 AmbientColor = vec4(u_lightColor, 1.0); // 环境光
vec4 diffuseColor = vec4(u_lightColor, 1.0) * nDotL; // 漫反射光的颜色
earthColor = earthColor * (AmbientColor + diffuseColor);
} else if (lookupColor.x == 0.8) { // 点击后选中的背景色
if (outlineColor > 0.0) {
earthColor = vec4(lineSelectedColor, 1); // 选中国家的国界颜色
} else {
earthColor = vec4(selectedColor, 1); // 选中国家后的陆地颜色
}
}
if (text.w > 0.3) { // 此处过滤数值越大 文字越细
earthColor = vec4(0.7, 0.7, 0.7, 1);
}
} else { // flag == 0.0 表示正在进行点击计算
earthColor = vec4(vec3(diffuse), 1.0);
}
gl_FragColor = earthColor;
}
varying vec2 vUv;
varying vec3 vNormal;
void main() {
vUv = uv;
vNormal = normal;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
点击交互
可视化不仅仅是静态的图形数据,还需要与人交互。
所以这个酷炫的地球就需要支持选中国家并且获取到国家名称。
地球的交互逻辑如下:
监听到鼠标点击
填充整个画布为黑色
设置uniforms.flag.value = 0;
手动进行一次render(需要注意在这之前要隐藏其他所有3D对象)
使用gl.readPixels带入点击信息来获取鼠标落点的颜色
通过映射表获取到对应的国家ID(可以准备更多的映射信息)
根据1-255
的落点色值为lookupCanvas对应位置的像素填充#CCCCCC
恢复正常渲染
{1:'PE',2:'BF',3:'FR',4:'LY',5:'BY',6:'PK',7:'ID',8:'YE',9:'MG',10:'BO',11:'CI',12:'DZ',13:'CH',14:'CM',15:'MK',16:'BW',17:'UA',18:'KE',19:'TW',20:'JO',21:'MX',22:'AE',23:'BZ',24:'BR',25:'SL',26:'ML',27:'CD',28:'IT',29:'SO',30:'AF',31:'BD',32:'DO',33:'GW',34:'GH',35:'AT',36:'SE',37:'TR',38:'UG',39:'MZ',40:'JP',41:'NZ',42:'CU',43:'VE',44:'PT',45:'CO',46:'MR',47:'AO',48:'DE',49:'SD',50:'TH',51:'AU',52:'PG',53:'IQ',54:'HR',55:'GL',56:'NE',57:'DK',58:'LV',59:'RO',60:'ZM',61:'IR',62:'MM',63:'ET',64:'GT',65:'SR',66:'EH',67:'CZ',68:'TD',69:'AL',70:'FI',71:'SY',72:'KG',73:'SB',74:'OM',75:'PA',76:'AR',77:'GB',78:'CR',79:'PY',80:'GN',81:'IE',82:'NG',83:'TN',84:'PL',85:'NA',86:'ZA',87:'EG',88:'TZ',89:'GE',90:'SA',91:'VN',92:'RU',93:'HT',94:'BA',95:'IN',96:'CN',97:'CA',98:'SV',99:'GY',100:'BE',101:'GQ',102:'LS',103:'BG',104:'BI',105:'DJ',106:'AZ',107:'MY',108:'PH',109:'UY',110:'CG',111:'RS',112:'ME',113:'EE',114:'RW',115:'AM',116:'SN',117:'TG',118:'ES',119:'GA',120:'HU',121:'MW',122:'TJ',123:'KH',124:'KR',125:'HN',126:'IS',127:'NI',128:'CL',129:'MA',130:'LR',131:'NL',132:'CF',133:'SK',134:'LT',135:'ZW',136:'LK',137:'IL',138:'LA',139:'KP',140:'GR',141:'TM',142:'EC',143:'BJ',144:'SI',145:'NO',146:'MD',147:'LB',148:'NP',149:'ER',150:'US',151:'KZ',152:'AQ',153:'SZ',154:'UZ',155:'MN',156:'BT',157:'NC',158:'FJ',159:'KW',160:'TL',161:'BS',162:'VU',163:'FK',164:'GM',165:'QA',166:'JM',167:'CY',168:'PR',169:'PS',170:'BN',171:'TT',172:'CV',173:'PF',174:'WS',175:'LU',176:'KM',177:'MU',178:'FO',179:'ST',181:'DM',182:'TO',183:'KI',184:'FM',185:'BH',186:'AD',187:'MP',188:'PW',189:'SC',190:'AG',191:'BB',192:'TC',193:'VC',194:'LC',195:'YT',196:'VI',197:'GD',198:'MT',199:'MV',200:'KY',201:'KN',202:'MS',203:'BL',204:'NU',205:'PM',206:'CK',207:'WF',208:'AS',209:'MH',210:'AW',211:'LI',212:'VG',213:'SH',214:'JE',215:'AI',217:'GG',218:'SM',219:'BM',220:'TV',221:'NR',222:'GI',223:'PN',224:'MC',225:'VA',226:'IM',227:'GU',228:'SG',}
这一逻辑依赖于第四步骤的手动render。
而如果快速的点击来触发多次render将会打破正常的动画帧率产生卡顿。
而如果在渲染性能差帧率低的机器上触发一次也有可能会导致轻危的卡顿。
并且你无法通过监听mousemove中来真正的响应鼠标滑动事件,因为mousemove一秒钟内触发的次数甚至会超过动画帧率。造成一秒渲染120+帧的明显卡顿。
飞线
飞线是用来表达具有目的性的数据。
使用THREE.ShaderMaterial
配合 THREE.CubicBezierCurve3
实现。
实现原理是在一条由许很多很多的点组成的贝塞尔曲线路径上不断的改变顶点的透明度与大小,达到线在飞的效果。
顶点着色器
是飞线的重头戏。
在进行贝塞尔曲线之前,我们需要对位置数据进行一次处理。
因为飞线要映射在球体上,而后台数据是不可能直接返回Vector3(x, y, z)
的数据供你使用的。
所以我们要进行一次转换,我们使用最简单的三角函数来进行转换:
/**
* 将平面经纬度转换为实际 x, y, z 坐标
* @param {Number} lng 经度
* @param {Number} lat 纬度
* @param {Number} radius 球体半经
*/
function getSpherePosition(lng = 0, lat = 0, radius = 100) {
if (lng < 0) {
lng += 360;
}
if (lat > 0) {
lat += 2;
}
const y = radius * Math.sin((lat * Math.PI) / 180);
const zx = radius * Math.cos((lat * Math.PI) / 180);
const x = zx * Math.sin((lng * Math.PI) / 180);
const z = zx * Math.cos((lng * Math.PI) / 180);
return new THREE.Vector3(x, y, z);
}
getSpherePosition(116.3, 39.9, 100) // => {x: 66.72652011739466, y: 66.78325554710466, z: -32.97830031328238}
// 三维三次贝塞尔曲线(v0起点,v1第一个控制点,v2第二个控制点,v3终点)
let v0, v1, v2, v3;
// 地球的半经是 100
v0 = getSpherePosition(start_lng, start_lat, 100);
v3 = getSpherePosition(end_lng, end_lat, 100);
const angle = v0.angleTo(v3);
let vtop = v0.clone().add(v3);
vtop = vtop.normalize().multiplyScalar(100);
let n;
if (angle <= 1) {
n = (params.globeRadius / 5) * angle;
} else if (angle > 1 && angle < 2) {
n = (params.globeRadius / 5) * Math.pow(angle, 2);
} else {
n = (params.globeRadius / 5) * Math.pow(angle, 1.5);
}
v1 = v0.clone().add(vtop).normalize().multiplyScalar(100 + n);
v2 = v3.clone().add(vtop).normalize().multiplyScalar(100 + n);
const curve = new THREE.CubicBezierCurve3(v0, v1, v2, v3);
const points = curve.getPoints(500);
const geometry = new THREE.Geometry().setFromPoints(points); // 带入贝塞尔曲线的顶点生成geometry
const { length } = points;
const percents = new Float32Array(length);
for (let i = 0; i < length; i += 1) {
percents[i] = i / length;
}
geometry.addAttribute('percent', new THREE.BufferAttribute(percents, 1)); // 传入500个从0到1的percent供顶点着色器使用
顶点着色器
是实现飞线的核心。
attribute float percent; // 针对着色器`逐顶点运行`特性的辅助参数
uniform float time; // 飞线当前的进度。这个参数将会随着动画从0到1不断增加
uniform float number; // 飞线路径长度
uniform float speed; // 飞线运行速度
uniform float length; // 飞线拖尾长度
uniform float size; // 飞线粗细
varying float opacity;
void main() {
float l = clamp(1.0 - length, 0.0, 1.0);
// 计算公式
gl_PointSize = clamp(fract(percent * number + l - time * number * speed) - l, 0.0, 1.0) * size * (1.0 / length);
opacity = gl_PointSize / size; // 供片元着色器使用,可自行修改以控制飞线拖尾的形状
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
varying float opacity;
uniform vec3 color; // 飞线颜色
void main() {
if (opacity <= 0.2) {
discard;
}
gl_FragColor = vec4(color, 1.0);
}
通过顶点实现的飞线,在顶点密度不足的情况下会出现异常。
这是因为着色器会按照屏幕像素来渲染大小,而不会因为相机的远近变化来放大缩小。
解决的办法有两种:
增加顶点的密度
更换飞线实现方式(使用官方开发的meshline
或自行开发)
3
小结
本章主要讲述了texture
、uniform
、attribute
三者与GLSL
配合使用的场景,并延伸出索引贴图
的解决方案。
下一章将会讲述传统3d平面地图的绘制方法和我们在实现地图相关产品时的其他注意事项。
往期精彩回顾
360技术公众号
技术干货|一手资讯|精彩活动
扫码关注我们