效果图:
预览地址:http://192.144.225.99/earth
实现过程
一、准备工作
渲染地球所需的图片资料,大部分都来自于参考网站
地区模型:使用blender以及blender、blender-osm
- 获取某个地区的模型数据(包含地形、路网、建筑)、卫星图。生成总模型。
- 获取到这个地区的边界图(我这里是用SVG)。
- 通过SVG生成一个边界模型。
- 两个模型作布尔操作,切出想要的轮廓。
- 导出模型。
这样就有一个地区的模型了,在生成模型的时候,可以对模型内的路网、建筑等做拆分、取名等操作。
这个模型在例子中就只是显示一下,主要是想记录下制作过程。
二、开发
项目环境准备
习惯使用vue,所以就用vue-cli搭建的项目,本示例项目跟项目环境关系不大,主要关注src/earth/index.ts文件即可。
基本场景
three.js基本场景:相机、渲染器、scene。
这里使用了两个渲染器,WebGLRenderer和CSS3DRenderer,因为要渲染文本,又不想多引入字体文件,所以使用dom的形式来渲染,就多出一个CSS3DRenderer
基本场景代码如下:
// 主场景
const scene = new Scene();
// 文本所在场景
const css3Scene = new Scene();
// 摄像机
const camera = new PerspectiveCamera(
...[
45, // fov — 摄像机视锥体垂直视野角度
1, // aspect — 摄像机视锥体长宽比
0.01, // near — 摄像机视锥体近端面
1000, // far — 摄像机视锥体远端面
]
);
// 主渲染器
const renderer = new WebGLRenderer({ alpha: true, antialias: true });
// 文本所在渲染器
const css3Renderer = new CSS3DRenderer();
// 加入鼠标控制
const controls = new OrbitControls(camera, renderer.domElement);
let mountDom!: HTMLElement;
const size = {
width: 0,
height: 0,
};
const offset = {
x: 0,
y: 0,
};
export const getDomSize = (currentDOM: HTMLElement) => {
return currentDOM.getBoundingClientRect();
};
const localGetDomSize = (mountDom: HTMLElement) => {
const rect = getDomSize(mountDom);
size.width = rect.width;
size.height = rect.height;
offset.x = rect.x;
offset.y = rect.y;
return rect;
};
// 适应window大小
export const onWindowResize = () => {
const { width, height } = localGetDomSize(mountDom);
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height);
css3Renderer.setSize(width, height);
};
const listen = () => {
window.addEventListener("resize", onWindowResize, false);
};
//渲染循环
const animate = () => {
requestAnimationFrame(animate);
// 动画更新
(TWEEN as any).update();
// 这里加了后处理,相当于renderer.render();
composer.render();
css3Renderer.render(scene, camera);
};
// 控制的最小距离与最大距离
export const controlSetting = {
minDistance: 1,
maxDistance: 160,
};
// 光照
const initLight = () => {
const group = new Group();
// lights
group.add(new AmbientLight(0x666666));
const light = new DirectionalLight(0xffffff, 1);
light.position.set(0, 1, 0);
group.add(light);
scene.add(group);
};
// 相机起始位置x: -8,
// y: 16,
// z: -24,
export const cameraInitPosition = new Vector3(-8, 16, -24).multiplyScalar(0.1);
// 开始函数
const init = (initDom: HTMLElement) => {
mountDom = initDom;
const { width, height } = localGetDomSize(mountDom);
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(width, height);
css3Renderer.setSize(width, height);
mountDom.appendChild(renderer.domElement);
const cssDom = css3Renderer.domElement;
cssDom.classList.add("css3Renderer");
mountDom.appendChild(cssDom);
controls.minDistance = controlSetting.minDistance;
controls.maxDistance = controlSetting.maxDistance;
camera.position.copy(cameraInitPosition);
camera.lookAt(0, 0, 0);
listen();
initLight();
animate();
// 加入参考轴
const help = new AxesHelper(20);
scene.add(help);
};
主要物体部分
场景中的物体主要包含:地球、云层、城市点、点之间的连线、卫星轨迹。
地球: 地球就是一个圆球的物体,这里使用three.js自带的球体。
// 内层球(地球本身),球体生成
const geo = new SphereBufferGeometry(10, 100, 100);
const mesh = new Mesh(geo, shaderMaterial);
主要是材质部分的片元着色器代码,先加载三个素材图片。
这里只需要lineTexture和fillTexture,mapTexture就是一个点的图片(本想还原参考网站的效果,但由于效果太丑,就没用它)。
用uniforms传递到片元着色器。使用lineTexture画出大陆的轮廓线,使用fillTexture填充大陆。
主要代码如下:
const lineTexture = new TextureLoader().load("./imgs/merge_from_ofoct.jpg");
const fillTexture = new TextureLoader().load("./imgs/earth_1.png");
const mapTexture = new TextureLoader().load("./imgs/dot.png");
const uniforms = {
lineTexture: { value: lineTexture },
fillTexture: { value: fillTexture },
mapTexture: { value: mapTexture },
};
// 内层球材质
const shaderMaterial = new ShaderMaterial({
uniforms: uniforms,
side: DoubleSide,
vertexShader: `
precision highp float;
varying vec2 vUv;
varying vec3 vNormal;
varying float _alpha;
void main() {
vUv = uv;
vNormal = normalize(normalMatrix * normal);
vec4 mvPosition = modelViewMatrix * vec4( position, 1.0 );
gl_Position = projectionMatrix * mvPosition;
}
`,
fragmentShader: `
uniform sampler2D lineTexture;
uniform sampler2D fillTexture;
uniform sampler2D mapTexture;
varying vec2 vUv;
varying vec3 vNormal;
varying float _alpha;
void main() {
vec4 lineColor = texture2D( lineTexture, vUv );
vec4 fillColor = texture2D( fillTexture, vUv );
// 由于我们希望得到一个球的两边亮一些的效果,
// 就得借助球表面的向量在Z轴上的投影的大小来达到变化颜色的效果
// vNormal代表每个垂直于球平面的向量,再点乘Z轴,因为摄像头是从Z向里看的,
// 所以这里我们取(0.0, 0.0, 1.0),Z轴
float silhouette = dot(vec3(0.0, 0.0, 1.0) ,vNormal );
lineColor = vec4(lineColor.rgb,1.0);
float z = gl_FragCoord.z;
if(lineColor.r <= 0.1) {
if(fillColor.r <= 0.1) {
float x = sin(vUv.x * 1000.0) * 0.5 + 0.5;
float y = sin(vUv.y * 1000.0) * 0.5 + 0.5;
vec4 mapColor = texture2D( mapTexture, vec2(x, y) );
// 球面变化关联到颜色
float c = pow(1.0 - abs(silhouette), 1.0);
if(c < 0.2) {
c = 0.2;
}
// lineColor = vec4(c,c,c, 1.0) * mapColor.rgb;
lineColor = vec4(c,c,c, 1.0);
} else {
discard;
}
}
gl_FragColor = vec4(lineColor.rgb * vec3(0.0,1.0,167.0 / 255.0), 1.0);
}
`,
transparent: true,
});
由于我们希望得到一个球的两边亮一些的效果,就得借助球表面的向量在Z轴(因为摄像头是从Z向里看的,所以这里我们取(0.0, 0.0, 1.0),Z轴)上的投影的大小来达到变化颜色,这里的vNormal代表每个垂直于球平面的向量,再点乘Z轴,就得到了一个随球面变化的值,再将这个值关联到显示的颜色,就达到了效果。
只有大陆轮廓的样子:
可以将源代码中
shaderMaterial的片元着色器参数的main方法调整为下面这样,得到一个只有大陆轮廓的地球。
void main() {
vec4 lineColor = texture2D( lineTexture, vUv );
vec4 fillColor = texture2D( fillTexture, vUv );
float silhouette = dot(vec3(0.0, 0.0, 1.0) ,vNormal );
lineColor = vec4(lineColor.rgb,1.0);
float z = gl_FragCoord.z;
if(lineColor.r <= 0.1) {
// 舍弃
discard;
// if(fillColor.r <= 0.1) {
// float c = pow(1.0 - abs(silhouette), 1.0);
// if(c < 0.2) {
// c = 0.2;
// }
// lineColor = vec4(c,c,c, 1.0);
// } else {
// discard;
// }
} else {
float c = pow(1.0 - abs(silhouette), 1.0);
lineColor = vec4(c,c,c, 1.0);
}
gl_FragColor = vec4(lineColor.rgb * vec3(0.0,1.0,167.0 / 255.0), 1.0);
}
实现原理:根据UV获取出lineTexture(就是下面这个图)上某一点的颜色,由于只有黑有白,就直接判断rgb其中一个就行。当这个点的r值小于等于0.1时,就舍弃;大于0.1时,就显示颜色,通过这种方式就获取到了下图中的白色轮廓。
包含轮廓与大陆填充的地球:
跟上面画轮廓类似的,使用下面带填充的大陆图,在轮廓图要舍弃像素点的时候,判断是
fillColor.r是否小于等于0.1,就是轮廓图黑色的部分与下图黑色的部分的交集,则显示颜色;否则,就舍弃。
云层:
云层与地球的实现方式类似,也是一个球上面贴图,通过图片的颜色判断是否应该显示颜色,通过球面上的法向量判断显示颜色的深浅。
城市点
先通过百度地图等工具找到区域的经纬度,再对经纬度做一下转换(网上找的小函数,再稍微修改下)。
生成圆柱体,设置圆柱体位置(通过经纬度转换而来),同时生成城市名文本。
// 基础经纬度信息
export const locationDataList: {
x: number;
y: number;
name: string;
position?: Vector3;
sprite?: CSS3DSprite;
}[] = [
{
x: 117.079938,
y: 36.779526,
name: "北京",
},
{
x: 113.304199,
y: 23.147831,
name: "广州",
},
{
x: 121.462143,
y: 31.222836,
name: "上海",
},
{
x: 117.211019,
y: 31.83075,
name: "合肥",
},
{
x: 120.262604,
y: 30.473382,
name: "杭州",
},
];
function lglt2v({ x: lng, y: lat }: { x: number; y: number }, radius: number) {
// 有点偏差,手动校对的
lng = lng - 12;
lat = lat - 3;
const phi = (180 + lng) * (Math.PI / 180);
const theta = (90 - lat) * (Math.PI / 180);
return new Vector3(
-radius * Math.sin(theta) * Math.cos(phi),
radius * Math.cos(theta),
radius * Math.sin(theta) * Math.sin(phi)
);
}
const generateLocals = () => {
const geo = new CircleBufferGeometry(0.08, 10);
const mater = new MeshBasicMaterial({ color: "red", side: DoubleSide });
// 模板物体
const temp = new Object3D();
// 城市点物体(包含所有城市点)
Locations = new InstancedMesh(geo, mater, locationDataList.length);
locationDataList.forEach((item, index) => {
// 根据经纬度获取位置
const v = lglt2v(item, 10);
// 改变模板物体位置、旋转角,再获取它的矩阵(包含位置、缩放、旋转信息)
temp.position.copy(v);
// 将模板物体面向原点(地球圆心)
temp.lookAt(new Vector3());
temp.updateMatrix();
const m = temp.matrix;
// 将每个城市点的矩阵设置到InstancedMesh中
Locations?.setMatrixAt(index, m);
item.position = v;
// 设置城市名文本文字
const element = document.createElement("div");
element.className = "element";
element.innerHTML = item.name;
const object = new CSS3DSprite(element);
object.scale.set(0.01, 0.01, 0.01);
object.position.copy(v.clone().multiplyScalar(1.1));
object.visible = false;
item.sprite = object;
App.text.add(object);
App.text.visible = false;
scene.add(App.text);
});
return Locations;
};
点之间的连线
const connectLine = (v0: Vector3, v1: Vector3) => {
// 向量v0与向量v1之间的3个向量
const midV = v0.clone().lerp(v1.clone(), 0.25);
const midV1 = v0.clone().lerp(v1.clone(), 0.5);
const midV2 = v0.clone().lerp(v1.clone(), 0.75);
const distance = v0.clone().distanceTo(v1.clone());
// 将这3个向量变长一些,数字10是地球的半径
midV.normalize().multiplyScalar(10 + distance / 8);
midV1.normalize().multiplyScalar(10 + distance / 6);
midV2.normalize().multiplyScalar(10 + distance / 8);
// 通过v0、v1、与它们之间的3个向量生成一条比较平滑的线
const spline = new CatmullRomCurve3(
[v0, midV, midV1, midV2, v1],
false,
"chordal",
0.5
);
// 取线上的30个点,生成meshline
const positions = spline.getPoints(30);
const geometry = new Geometry();
geometry.vertices.push(...positions);
const meshLine = new MeshLine();
meshLine.setGeometry(geometry);
const resolution = new Vector2(window.innerWidth, window.innerHeight);
const material = new MeshLineMaterial({
useMap: false,
color: new Color("red"),
opacity: 1,
resolution: resolution,
dashArray: 0.0,
dashRatio: 0,
dashOffset: 0,
sizeAttenuation: true,
lineWidth: 0.02,
transparent: true,
near: camera.near,
far: camera.far,
});
const line = new Mesh(meshLine.geometry, material);
App.earth.add(line);
};
卫星轨迹
生成3个线圈,再让其以不同的角度、速度旋转。通过深度(gl_FragCoord.z / gl_FragCoord.w可以得到当前片元和camera之间的距离),来改变颜色深浅。
const SatelliteLineShaderMaterial = new ShaderMaterial({
side: DoubleSide,
vertexShader: `
precision highp float;
void main() {
vec4 mvPosition = modelViewMatrix * vec4( position, 1.0 );
gl_Position = projectionMatrix * mvPosition;
}
`,
fragmentShader: `
void main() {
float length = 1.0 - (gl_FragCoord.z / gl_FragCoord.w) / 40.0;
if(length > 1.0) {
length = 1.0;
} else if(length < 0.3) {
length = 0.3;
}
gl_FragColor = vec4(vec3(0.0,1.0,167.0 / 255.0) * length, length);
}
`,
transparent: true,
});
源码地址:github.com/snhwv/earth…
这个例子的主要实现就讲完啦~