如何保证两个不同宽高的canvas用同一组坐标正常显示_【第2104期】如何1人5天开发完3D数据可视化大屏之一...

前言

这种数据大屏临近双十一又可以见到了。今日早读文章由360@靖轩投稿,公号:幻视可视化分享。

正文从这开始~~

相信从事过数据可视化开发的你对大屏并不陌生,那么开发一个酷炫的大屏一定是很多数据可视化开发者想要做的事情。

我们使用three.js,大约一周的时间开发出了一个酷炫的数据可视化大屏:

05984d7616b9e082197e81514b4265b4.gif

由于篇幅问题,整篇会分为两个部分,围绕以下几个核心分享:

第一部分
  • 地球的实现

  • 地球可点击的交互逻辑

  • 飞线的实现

第二部分
  • 平面地图的实现

  • 柱体的实现

  • 性能优化

  • 地图相关问题

涉及到的知识点
  • GLSL:着色器在各3D对象中的应用

  • THREE.ShaderMaterial:three.js与着色器的复合应用

  • THREE.Texture:贴图与着色器的复合应用

  • THREE.CubicBezierCurve3:三次三维空间贝塞尔曲线

  • THREE.CylinderGeometry:如何基于数据为圆柱几何体上色

使用的技术栈
  • vue

  • webpack

  • three.js

  • antv

  • d3.js

酷炫的地球

在我们的大屏中,酷炫的地球作为颜值担当,有效的撑起了场面。

96d9899d8ab1160dc4096e2d0d695336.gif

地球

地球使用THREE.ShaderMaterial实现,它由多张贴图材质构成,而非使用多面模型。

他承载了球体本身和点击交互。

地球由五张贴图组成:

贴图1 : mapIndex

b64a62a527258631680160e8a1d21d1d.png

这张索引贴图为每个国家分配 1 - 255 之间不同的索引颜色。部分国家颜色只是看似相近,实际数值不同。

非陆地部分的颜色为 0。

他用于我们在做点击交互时识别点击位置的国家和GLSL为选择的国家上色。

在使用时需要注意:贴图不能出现模糊、羽化等现象,使用photoshop编辑时要使用铅笔笔触。否则会影响到片元着色器的计算。

贴图2 : lookup

他是一张 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

} elseif(lookupColor.x == 0.0) { // 海洋 #000000

} elseif(lookupColor.x == 0.8) { // 被选中的国家 #CCCCCC

}

gl_FragColor = earthColor;

}

贴图3 : outline

60375dc6d09ed0e920ca96ccc1facd77.png

这张贴图勾勒出了国家边界。

这张索引贴图不同于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{ // 国家颜色

}

} elseif(lookupColor.x == 0.0) { // 海洋 #000000

} elseif(lookupColor.x == 0.8) { // 被选中的国家 #CCCCCC

if(outlineColor > 0.0) { // 0.0 代表显示当前国家区域内所有的边界

} else{

}

}

gl_FragColor = earthColor;

}

贴图4 : textTexture

这张贴图仅仅是写了几个国家的名字。

使用时文字贴图会优先所有判断,从而显示在球体上。

需要注意的是:球体会按极坐标使用贴图,所以写在离南北极较近地方的文字要随着纬度拉的胖一些。

uniform sampler2D textTexture;

varying vec2 vUv;

void main() {

vec4 text = texture2D(textTexture, vUv);

if(lookupColor.x == 1.0) {

} elseif(lookupColor.x == 0.0) {

} elseif(lookupColor.x == 0.8) {

}

if(text.w > 0.3) { // 此处过滤数值越大 文字越细

earthColor = vec4(0.7, 0.7, 0.7, 1); // 文字颜色;覆盖前面的颜色计算

}

}

贴图5 : depthTexture

49fea6bb1a6d73e14b791a668b44c199.png

这张贴图描绘了海洋的深度。

在片元着色器计算时判断为海洋的位置将会使用海洋的贴图。

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;

}

其他uniform

除了贴图外,我们还要定义 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); // 国家的颜色

}

} elseif(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);

} elseif(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);

}

4154a7772f65cdd0027a330baa121a1a.png

点击交互

可视化不仅仅是静态的图形数据,还需要与人交互。

所以这个酷炫的地球就需要支持选中国家并且获取到国家名称。

交互逻辑

e04094a445c55c41e7f6aa02e7b6e376.png

地球的交互逻辑如下:

  • 监听到鼠标点击

  • 填充整个画布为黑色

  • 设置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+帧的明显卡顿。

飞线

飞线是用来表达具有目的性的数据。

b25a50046666c3d83700c2babf333374.gif

使用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);

returnnew 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;

} elseif(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 = newFloat32Array(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);

}

弊端

通过顶点实现的飞线,在顶点密度不足的情况下会出现异常。

3004a144860ddf3300d6c859f6873266.png

这是因为着色器会按照屏幕像素来渲染大小,而不会因为相机的远近变化来放大缩小。

解决的办法有两种:

  • 增加顶点的密度

  • 更换飞线实现方式(使用官方开发的meshline或自行开发)

小结

本章主要讲述了texture、uniform、attribute三者与GLSL配合使用的场景,并延伸出索引贴图的解决方案。

下一章将会讲述传统3d平面地图的绘制方法和我们在实现地图相关产品时的其他注意事项。

关于本文 作者:@张靖石 原文:https://mp.weixin.qq.com/s/8f_VIeQt0VBdLLUg5_j3aw

ff41f2c4fa26e256bf1ae6bbbe31a121.png

为你推荐

【第2099期】可视化库的设计空间

【第1983期】如何挑选数据可视化框架及平台 - 前端篇

欢迎自荐投稿,前端早读课等你来

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值