JS前端三維地球渲染——中國各城市航空路線展示


前言

我還從來沒有寫過有關純JS的文章(上次的矢量瓦片展示除外,相對較簡單。),自己也學習過JS、CSS等前端知識,了解JQuery、React等框架,但是自己藝術天分實在不過關,不太喜歡前端設計,比較喜歡后台的邏輯處理。

昨天整理自己收藏的東西,無意中看到一個3維地球展示的開源框架,非常漂亮,怎么自己當時僅是收藏並未研究呢?於是喜歡技術無法自拔的我不分三七二十一,立馬開始研究。

框架介紹

框架名稱為PhiloGL,從名字就能看出這是一個用來顯示3維物體的WebGL框架。其官網介紹為:

PhiloGL is a WebGL Framework for Data Visualization, Creative Coding and Game Development

大意是一個數據可視化、創意編碼和游戲開發的WebGL框架。官網中提供了很多酷炫的3維實例,各位可以在其中找到自己感興趣的東西。這段時間我一直在做GIS方向,於是看到3維地球就無法自拔,DEMO位置http://www.senchalabs.org/philogl/PhiloGL/examples/worldFlights/。這是一個全球航空路線的3維展示,用戶可以選擇不同的航空公司進行展示。截圖如下:

DEMO

我的工作

看到這么酷炫的東西當然想要變成自己的,這是一個老程序員對技術不懈執着追求的內發原因。我本來想做一個中國春運期間遷徙圖,奈何搜了半天居然找不到數據,沒有數據一切豈不是白扯。航空路線是一個非常好的展示,但是人家DEMO都已經給出了,我總不能拿來就說是我做的吧?那么只能稍微改點東西,一來假裝東西是自己做的,二來也算是對整個框架的一個學習。本身以為是個很輕松的事情,沒想到卻比想象中復雜的多。我實現的功能是根據中國的城市顯示對應的航空路線,即當列表中選擇某城市時,在3維中畫出進出此城市的所有航線。效果如下:

My Work

示例演示頁面:http://wsf1990.gitee.io/airline_china/airline_china.html,oschina地址:https://gitee.com/wsf1990/airline_china

原理淺析

本文不做過深的技術探討(因為我也還沒吃透😊),此文的用意為一來大家介紹一款優秀的WebGL框架,二來拋磚引玉,為大家提供一點小小的方向。可以在https://github.com/senchalabs/philogl中找到框架源碼及示例源碼。

1. 創建三維場景

首先創建一個畫布canvas,所以必須是支持HTML5的瀏覽器才能正常訪問。代碼如下:

<canvas id="map-canvas" width="1024" height="1024"></canvas>

簡單的添加一個canvas。

然后在js中使用如下代碼創建三維場景:

PhiloGL('map-canvas', {
	program: [{
		//to render cities and routes
		id: 'airline_layer',
		from: 'uris',
		path: 'shaders/',
		vs: 'airline_layer.vs.glsl',
		fs: 'airline_layer.fs.glsl',
		noCache: true
	}, {
		//to render cities and routes
		id: 'layer',
		from: 'uris',
		path: 'shaders/',
		vs: 'layer.vs.glsl',
		fs: 'layer.fs.glsl',
		noCache: true
	},{
		//to render the globe
		id: 'earth',
		from: 'uris',
		path: 'shaders/',
		vs: 'earth.vs.glsl',
		fs: 'earth.fs.glsl',
		noCache: true
	}, {
		//for glow post-processing
		id: 'glow',
		from: 'uris',
		path: 'shaders/',
		vs: 'glow.vs.glsl',
		fs: 'glow.fs.glsl',
		noCache: true
	}],
	camera: {
		position: {
			x: 0, y: 0, z: -5.125
		}
	},
	scene: {
		lights: {
			enable: true,
			ambient: {
				r: 0.4,
				g: 0.4,
				b: 0.4
			},
			points: {
				diffuse: {
					r: 0.8,
					g: 0.8,
					b: 0.8
				},
				specular: {
					r: 0.9,
					g: 0.9,
					b: 0.9
				},
				position: {
					x: 2,
					y: 2,
					z: -4
				}
			}
		}
	},
	events: {
		picking: true,
		centerOrigin: false,
		onDragStart: function(e) {
			pos = pos || {};
			pos.x = e.x;
			pos.y = e.y;
			pos.started = true;

			geom.matEarth = models.earth.matrix.clone();
			geom.matCities = models.cities.matrix.clone();
		},
		onDragMove: function(e) {
			var phi = geom.phi,
				theta = geom.theta,
				clamp = function(val, min, max) {
					return Math.max(Math.min(val, max), min);
				},
				y = -(e.y - pos.y) / 100,
				x = (e.x - pos.x) / 100;

			rotateXY(y, x);

		},
		onDragEnd: function(e) {
			var y = -(e.y - pos.y) / 100,
				x = (e.x - pos.x) / 100,
				newPhi = (geom.phi + y) % Math.PI,
				newTheta = (geom.theta + x) % (Math.PI * 2);

			newPhi = newPhi < 0 ? (Math.PI + newPhi) : newPhi;
			newTheta = newTheta < 0 ? (Math.PI * 2 + newTheta) : newTheta;

			geom.phi = newPhi;
			geom.theta = newTheta;

			pos.started = false;

			this.scene.resetPicking();
		},
		onMouseWheel: function(e) {
			var camera = this.camera,
				from = -5.125,
				to = -2.95,
				pos = camera.position,
				pz = pos.z,
				speed = (1 - Math.abs((pz - from) / (to - from) * 2 - 1)) / 6 + 0.001;

			pos.z += e.wheel * speed;

			if (pos.z > to) {
				pos.z = to;
			} else if (pos.z < from) {
				pos.z = from;
			}

			clearTimeout(this.resetTimer);
			this.resetTimer = setTimeout(function(me) {
				me.scene.resetPicking();
			}, 500, this);

			camera.update();
		},
		onMouseEnter: function(e, model) {
			if (model) {
				clearTimeout(this.timer);
				var style = tooltip.style,
					name = data.citiesIndex[model.$pickingIndex].split('^'),
					textName = name[1][0].toUpperCase() + name[1].slice(1) + ', ' + name[0][0].toUpperCase() + name[0].slice(1),
					bbox = this.canvas.getBoundingClientRect();

				style.top = (e.y + 10 + bbox.top) + 'px';
				style.left = (e.x + 5 + bbox.left) + 'px';
				this.tooltip.className = 'tooltip show';

				this.tooltip.innerHTML = textName;
			}
		},
		onMouseLeave: function(e, model) {
			this.timer = setTimeout(function(me) {
				me.tooltip.className = 'tooltip hide';
			}, 500, this);
		}
	},
	textures: {
		src: ['img/lala.jpg']
	},
	onError: function() {
		Log.write("There was an error creating the app.", true);
	},
	onLoad: function(app) {
		Log.write('Done.', true);

		//Unpack app properties
		var gl = app.gl,
			scene = app.scene,
			camera = app.camera,
			canvas = app.canvas,
			width = canvas.width,
			height = canvas.height,
			program = app.program,
			clearOpt = gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT;

		app.tooltip = $('tooltip');
		//nasty
		centerAirline.app = app;
		cityMgr.app = app;

		gl.clearColor(0.1, 0.1, 0.1, 1);
		gl.clearDepth(1);
		gl.enable(gl.DEPTH_TEST);
		gl.depthFunc(gl.LEQUAL);

		//create shadow, glow and image framebuffers
		app.setFrameBuffer('world', {
			width: 1024,
			height: 1024,
			bindToTexture: {
				parameters : [ {
					name : 'TEXTURE_MAG_FILTER',
					value : 'LINEAR'
				}, {
					name : 'TEXTURE_MIN_FILTER',
					value : 'LINEAR',
					generateMipmap : false
				} ]
			},
			bindToRenderBuffer: true
		}).setFrameBuffer('world2', {
			width: 1024,
			height: 1024,
			bindToTexture: {
				parameters : [ {
					name : 'TEXTURE_MAG_FILTER',
					value : 'LINEAR'
				}, {
					name : 'TEXTURE_MIN_FILTER',
					value : 'LINEAR',
					generateMipmap : false
				} ]
			},
			bindToRenderBuffer: true
		});

		//picking scene
		scene.add(models.earth, models.cities);

		draw();
		
		//Draw to screen
		function draw() {
			// render to a texture
			gl.viewport(0, 0, 1024, 1024);

			program.earth.use();
			program.earth.setUniform('renderType',  0);
			app.setFrameBuffer('world', true);
			gl.clear(clearOpt);
			scene.renderToTexture('world');
			app.setFrameBuffer('world', false);

			program.earth.use();
			program.earth.setUniform('renderType',  -1);
			app.setFrameBuffer('world2', true);
			gl.clear(clearOpt);
			scene.renderToTexture('world2');
			app.setFrameBuffer('world2', false);

			Media.Image.postProcess({
				fromTexture: ['world-texture', 'world2-texture'],
				toScreen: true,
				program: 'glow',
				width: 1024,
				height: 1024
			});

			Fx.requestAnimationFrame(draw);
		}
	}
});

我之前很少接觸3D,只接觸過一點Unity3D,還是對.NET技術棧瘋狂喜愛的時候,當然我現在也很喜歡C#語言,但是會更加理性的對待每一種語言,用適合的語言做適合的事情。所以上面這段代碼不是看的非常明白,大意是設置貼圖、鏡頭(從哪個角度觀看此三維場景)、提示(tooltip)及拖拽等事件等。

其中maodels.earth定義如下:

models.earth = new O3D.Sphere({
    nlat: 150,
    nlong: 150,
    radius: 1,
    uniforms: {
        shininess: 32
    },
    textures: ['img/lala.jpg'],
    program: 'earth'
});

這里創建了一個三維球,當然也可以創建立方圖等基礎三維模型。

2. 請求數據

可以直接采用框架原生的ajax請求方式,不需要使用JQuery。格式如下:

new IO.XHR({
    url: 'data/cities.json',
    onSuccess: function(json) {
        data.cities = JSON.parse(json);
        citiesWorker.postMessage(data.cities);
        Log.write('Building models...');
    },
    onProgress: function(e) {
        Log.write('Loading airports data, please wait...' +
            (e.total ? Math.round(e.loaded / e.total * 1000) / 10 : ''));
    },
    onError: function() {
        Log.write('There was an error while fetching cities data.', true);
    }
}).send();

直接使用IO.XHR請求文本數據,數據可以是任意格式的,自己解析即可。也可以請求其他種類數據,封裝的有:

IO.XHR = XHR;
IO.JSONP = JSONP;
IO.Images = Images;
IO.Textures = Textures;

JSONP為跨域請求,Images請求圖片數據,Textures請求貼圖數據,Images與Textures基本相同,后者是對前者的封裝。具體用法可以參見源碼。

請求數據函數清晰明了,onSuccess表示請求成功之后的回調函數。onProgress表示請求過程的回調函數,onError更不必說。

3. 加載線路

獲取到城市數據、航線數據等之后,通過每條航線的起點和終點在三維地球中繪制出三維航線。代碼如下:

var CityManager = function(data, models) {

    var cityIdColor = {};

    var availableColors = {
        '171, 217, 233': 0,
        '253, 174, 97': 0,
        '244, 109, 67': 0,
        '255, 115, 136': 0,
        '186, 247, 86': 0,
        '220, 50, 50': 0
    };

    var getAvailableColor = function() {
        var min = Infinity,
            res = false;
        for (var color in availableColors) {
            var count = availableColors[color];
            if (count < min) {
                min = count;
                res = color;
            }
        }
        return res;
    };

    return {

        cityIds: [],

        getColor: function(cityId) {
            return cityIdColor[cityId];
        },

        getAvailableColor: getAvailableColor,

        add: function(city) {
            var cityIds = this.cityIds,
                color = getAvailableColor(),
                routes = data.airlinesRoutes[city],
                airlines = models.airlines,
                model = airlines['10'],
                samplings = 10,
                vertices = [],
                indices = [],
                fromTo = [],
                sample = [],
                parsedColor;

            parsedColor = color.split(',');
            parsedColor = [parsedColor[0] / (255 * 1.3),
                parsedColor[1] / (255 * 1.3),
                parsedColor[2] / (255 * 1.3)];

            if (model) {
                model.uniforms.color = parsedColor;
            } else {

                for (var i = 0, l = routes.length; i < l; i++) {
                    var ans = this.createRoute(routes[i], vertices.length / 3);
                    vertices.push.apply(vertices, ans.vertices);
                    fromTo.push.apply(fromTo, ans.fromTo);
                    sample.push.apply(sample, ans.sample);
                    indices.push.apply(indices, ans.indices);
                }

                airlines[city] = model = new O3D.Model({
                    vertices: vertices,
                    indices: indices,
                    program: 'airline_layer',
                    uniforms: {
                        color: parsedColor
                    },
                    render: function(gl, program, camera) {
                        gl.lineWidth(this.lineWidth || 1);
                        gl.drawElements(gl.LINES, this.$indicesLength, gl.UNSIGNED_SHORT, 0);
                    },
                    attributes: {
                        fromTo: {
                            size: 4,
                            value: new Float32Array(fromTo)
                        },
                        sample: {
                            size: 1,
                            value: new Float32Array(sample)
                        }
                    }
                });

                model.fx = new Fx({
                    transition: Fx.Transition.Quart.easeOut
                });
            }

            this.show(model);

            cityIds.push(city);
            availableColors[color]++;
            cityIdColor[city] = color;
        },

        remove: function(airline) {
            var airlines = models.airlines,
                model = airlines[airline],
                color = cityIdColor[airline];

            this.hide(model);

            //unset color for airline Id.
            availableColors[color]--;
            delete cityIdColor[airline];
        },

        show: function(model) {
            model.uniforms.animate = true;
            this.app.scene.add(model);
            model.fx.start({
                delay: 0,
                duration: 1800,
                onCompute: function(delta) {
                    model.uniforms.delta = delta;
                },
                onComplete: function() {
                    model.uniforms.animate = false;
                }
            });
        },

        hide: function(model) {
            var me = this;
            model.uniforms.animate = true;
            model.fx.start({
                delay: 0,
                duration: 900,
                onCompute: function(delta) {
                    model.uniforms.delta = (1 - delta);
                },
                onComplete: function() {
                    model.uniforms.animate = false;
                    me.app.scene.remove(model);
                }
            });
        },

        getCoordinates: function(from, to) {
            var pi = Math.PI,
                pi2 = pi * 2,
                sin = Math.sin,
                cos = Math.cos,
                theta = pi2 - (+to + 180) / 360 * pi2,
                phi = pi - (+from + 90) / 180 * pi,
                sinTheta = sin(theta),
                cosTheta = cos(theta),
                sinPhi = sin(phi),
                cosPhi = cos(phi),
                p = new Vec3(cosTheta * sinPhi, cosPhi, sinTheta * sinPhi);

            return {
                theta: theta,
                phi: phi,
                p: p
            };
        },

        //creates a quadratic bezier curve as a route
        createRoute: function(route, offset) {
            var key1 = route[2] + '^' + route[1],
                city1 = data.cities[key1],
                key2 = route[4] + '^' + route[3],
                city2 = data.cities[key2];

            if (!city1 || !city2) {
                return {
                    vertices: [],
                    from: [],
                    to: [],
                    indices: []
                };
            }

            var c1 = this.getCoordinates(city1[2], city1[3]),
                c2 = this.getCoordinates(city2[2], city2[3]),
                p1 = c1.p,
                p2 = c2.p,
                p3 = p2.add(p1).$scale(0.5).$unit().$scale(p1.distTo(p2) / 3 + 1.2),
                theta1 = c1.theta,
                theta2 = c2.theta,
                phi1 = c1.phi,
                phi2 = c2.phi,
                pArray = [],
                pIndices = [],
                fromTo = [],
                sample = [],
                t = 0,
                count = 0,
                samplings = 10,
                deltat = 1 / samplings;

            for (var i = 0; i <= samplings; i++) {
                pArray.push(p3[0], p3[1], p3[2]);
                fromTo.push(theta1, phi1, theta2, phi2);
                sample.push(i);

                if (i !== 0) {
                    pIndices.push(i -1, i);
                }
            }

            return {
                vertices: pArray,
                fromTo: fromTo,
                sample: sample,
                indices: pIndices.map(function(i) { return i + offset; }),
                p1: p1,
                p2: p2
            };
        }
    };

};

此段代碼完成航線的顏色選擇和起點、終點角度計算,根據此便可繪制出三維效果的航線。

4. 按城市選擇

思路也很清晰,在列表中選擇城市之后,請求所有航線,然后只取出那些起點或終點為此城市的航線並采用上述方式進行加載。

總結

本文介紹了PhiloGL框架,並粗略介紹了如何使用其繪制中國城市航空路線。本文的目的不在於介紹其如何使用,因為關於3維方面我還欠缺太多知識,只是為大家提供一種思路,個人認為一個程序員思路才是最重要的。后續如果對此框架有新的理解會重新撰文說明。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM