接下来我会分步骤讲解,在线示例在数字孪生平台。
先添加一个球体
我们用threejs中的SphereGeometry
来创建球体,给他贴一张地球纹理。
let earthGeo = new THREE.SphereGeometry(10, 64, 64)
let earthMat = new THREE.MeshStandardMaterial({
map: albedoMap,
})
this.earth = new THREE.Mesh(earthGeo, earthMat)
this.group.add(this.earth)
然后写一个函数让这个球体能够进行自转,并且该函数每帧都会调用。
updateScene(interval, elapsed) {
this.controls.update()
this.stats1.update()
this.earth.rotateY(interval * 0.005 * params.speedFactor)
}
在屏幕上我们就会得到如下的效果。
添加凹凸贴图
地球上有山脉海洋,本质上是凹凸不平的,在太阳下也会呈现不同的阴影效果。那么为了让我们的地球更加真实,我们可以用 NASA 对外提供的地球的凹凸贴图,将其应用到地球的MeshStandardMaterial.
this.dirLight = new THREE.DirectionalLight(0xffffff, params.sunIntensity)
this.dirLight.position.set(-50, 0, 30)
scene.add(this.dirLight)
let earthMat = new THREE.MeshStandardMaterial({
map: albedoMap,
bumpMap: bumpMap,
bumpScale: 0.03,
})
这样我们就能够立即看山脉在阳光照耀下投下的阴影。
bumpScale
这个值要尽量小一点,如果太大的话,会发现即使在地球的阴影面,山脉也会被照亮。
添加云
NASA官方同样提供了云的贴图。我们将云的贴图添加到一个单独的网格球体,并且半径要比地球大。这么做是为了后面模拟云在地球上投射阴影的效果。
const cloudsMap = await loadTexture(Clouds)
...
let cloudGeo = new THREE.SphereGeometry(10.05, 64, 64)
let cloudsMat = new THREE.MeshStandardMaterial({
alphaMap: cloudsMap,
transparent: true,
})
this.clouds = new THREE.Mesh(cloudGeo, cloudsMat)
this.group.add(this.clouds)
添加云后真个效果就会看起来更加真实。
接下来我们实现云的投射阴影效果。具体做法:在地球纹理贴图上的任意 uv 点(X 点)上,找到云图上叠加的 uv 点(Y 点),提取 Y 点的颜色值。然后,我们根据 Y 点的颜色值(即 Y 点云的强度)使 X 点的颜色值变暗。
同时,因为云的旋转速度是地球的两倍,为了在地球的片段着色器中获得正确的阴影(云)位置,我们需要减去地球的坐标 uv_xOffset
,这样结果将在 -1 到 1 的范围内,我们还需要为云纹理的wrapS
设置RepeatWrapping
,以便texture2D仍然适用于-1到0。
云层越浓的地方阴影应该越浓,因此我们用1.0减去cloudsMapValue
来获得shadowValue,并将其乘以diffuseColor,我们还将 ShadowValue 限制为最小值 0.2,这样它就不会变得太暗。
shader.uniforms.tClouds = { value: cloudsMap }
shader.uniforms.tClouds.value.wrapS = THREE.RepeatWrapping;
shader.uniforms.uv_xOffset = { value: 0 }
shader.fragmentShader = shader.fragmentShader.replace('#include <emissivemap_fragment>', `
// 因为这是在完成照明计算之前以及应用大多数默认颜色/纹理计算之后
#include <emissivemap_fragment>
float cloudsMapValue = texture2D(tClouds, vec2(vMapUv.x - uv_xOffset, vMapUv.y)).r;
diffuseColor.rgb *= max(1.0 - cloudsMapValue, 0.2 );`)
updateScene(interval, elapsed) {
...
this.earth.rotateY(interval * 0.005 * params.speedFactor)
this.clouds.rotateY(interval * 0.01 * params.speedFactor)
// 计算 uv_xOffset 并将其传递给地球的 MeshStandardMaterial 使用的着色器
// X 点每旋转 n 弧度,Y 点就会旋转 2n 弧度
// 因此,Y 点的 uv.x 始终等于 X 点的 (uv.x - n / 2π)
// n 除以 2π 是将弧度(即 0 到 2π)转换为 uv 空间(即 0 到 1)
// 偏移量 n / 2π 将通过 uv_xOffset 传递到着色器程序中
// "offset % 1" 是因为 uv.x 的值 1 意味着完整的圆
// 每当 uv_xOffset 大于 1 时,偏移 2π 弧度就像根本没有偏移一样
const shader = this.earth.material.userData.shader
if ( shader ) {
let offset = (interval * 0.005 * params.speedFactor) / (2 * Math.PI)
shader.uniforms.uv_xOffset.value += offset % 1
}
}
完成这些更改后,现在我们得到了微妙但逼真的云阴影。
让海洋反射阳光
接下来我们可以让海洋看起来更加真实。水是一种很强的光反射体,尤其是当波浪安静或光的入射角较小时。通过利用粗糙度roughness
和金属度metalness
,这对于 MeshStandardMaterial 来说并不难做到。
const oceanMap = await loadTexture("./assets/Ocean.png")
const params = {
...
metalness: 0.1,
}
let earthMat = new THREE.MeshStandardMaterial({
map: albedoMap,
bumpMap: bumpMap,
bumpScale: 0.03, // 一定要很小,如果太大连背面的凸的地方都会被照亮
roughnessMap: oceanMap, // 以海洋贴图代替粗糙度贴图,将在着色器中反转灰度值
metalness: params.metalness, // 与金属度贴图的纹理值相乘
metalnessMap: oceanMap,
})
earthMat.onBeforeCompile = function( shader ) {
...
shader.fragmentShader = shader.fragmentShader.replace('#include <roughnessmap_fragment>', `
float roughnessFactor = roughness;
#ifdef USE_ROUGHNESSMAP
vec4 texelRoughness = texture2D( roughnessMap, vRoughnessMapUv );
texelRoughness = vec4(1.0) - texelRoughness;
roughnessFactor *= clamp(texelRoughness.g, 0.5, 1.0);
#endif
`);
...
}
因为这里用的海洋贴图其实就只有黑白色,如果海洋区域为白色,我们必须反转黑白值以使粗糙度正确,因为我们希望陆地的粗糙度为“白色”(值:1)。对于水域,我们不希望它的粗糙度为零,因为这会使其看起来太像金属并且反光效果太强,海洋会失去其通常的蓝色,因此我们将粗糙度值限制在最小值 0.5。
夜景图
如果地球的夜晚是完全黑暗的,那就没有意义了,因为通常情况下都是会有夜景灯光。那我们接下的实现逻辑就是:在白天显示正常地球贴图,在阳光照不到的区域,用夜景图代替。
const lightsMap = await loadTexture(NightLights)
...
let earthMat = new THREE.MeshStandardMaterial({
...
emissiveMap: lightsMap,
emissive: new THREE.Color(0xffff88),
})
earthMat.onBeforeCompile = function( shader ) {
...
shader.fragmentShader = shader.fragmentShader.replace('#include <emissivemap_fragment>', `
#ifdef USE_EMISSIVEMAP
vec4 emissiveColor = texture2D( emissiveMap, vEmissiveMapUv );
// geometryNormal是视图空间中的标准化法线
// 对于地球的夜晚一侧,geometryNormal和方向光之间的点积将为负
// 对于地球的光照面,情况相反,因此 emissiveColor 将乘以 0
// smoothstep用来平滑昼夜的变化
emissiveColor *= 1.0 - smoothstep(-0.02, 0.0, dot(geometryNormal, directionalLights[0].direction));
totalEmissiveRadiance *= emissiveColor.rgb;
#endif
...
`)
...
}
添加大气菲涅尔效果
菲涅尔效应可以想象,当我们在湖边看到离我们较远的湖水表面比我们近处的表面更清晰的反射时产生的效果;其实就是理解成,可以以更小的角度来更清晰地看到表面的反射。
虽然我们的海洋已经有了反光效果,但我们仍然缺少大气层。我们将分两步模拟大气效果:
- 调整地球纹理的漫反射颜色
diffuseColor
,使其在观察者观察到的球体边缘附近呈现出更亮的蓝色(由于从较厚的路径积累了更多的光,边缘总是看起来更亮,看看下面的b
比a
路径长得多)
- 在云层和地球网格之上添加另一个球体网格作为大气层本身。
shader.fragmentShader = shader.fragmentShader.replace('#include <emissivemap_fragment>', `
...计算自发光
...计算云影
// 添加少量大气菲涅耳效果,使其更加真实
// 微调下面的第一个常数以获得更强或更弱的效果
float intensity = 1.4 - dot( geometryNormal, vec3( 0.0, 0.0, 1.0 ) );
vec3 atmosphere = vec3( 0.3, 0.6, 1.0 ) * pow(intensity, 5.0);
diffuseColor.rgb += atmosphere;
`)
geometryNormal
是视图空间中地球表面的法线,视图空间就是以相机为原点的3D空间。所以 vec3( 0.0, 0.0, 1.0 ) 将是一个指向你自己(相机)的归一化向量。因此,在地球表面中心,dot(geometryNormal, vec3(0.0, 0.0, 1.0))
的结果将恰好是1.0;从观察者(相机)的角度来看,地球边缘点结果将为 0.0,地球背面中心点的结果将为 -1.0。vec3( 0.3, 0.6, 1.0 )
只是浅蓝色,想让效果更真实。
添加大气层
这将是我们要添加到地球上的最后一个效果。我们都看过从太空拍摄的地球的美丽照片,周围总是有一圈浅蓝色的细带,这就是大气层。至于材质,我们将使用 ShaderMaterial
,这意味着我们将提供自己的顶点和片段着色器。
varying vec3 vNormal;
varying vec3 eyeVector;
void main() {
vec4 mvPos = modelViewMatrix * vec4( position, 1.0 );
vNormal = normalize( normalMatrix * normal );
eyeVector = normalize(mvPos.xyz);
gl_Position = projectionMatrix * mvPos;
}
这里的 gl_Position
仍然使用标准公式计算,通常在一行内完成,但我将其分为两步,因为我想将 mvPos
的归一化值分配给 eyeVector
。这两个变量将与 vNormal
一起用于片段着色器。
varying vec3 vNormal;
varying vec3 eyeVector;
uniform float atmOpacity;
uniform float atmPowFactor;
uniform float atmMultiplier;
void main() {
// 从边缘开始到地球背部的中心,dotP会从0增加到1
float dotP = dot( vNormal, eyeVector );
// 该因子是为了创造真实的大气增厚的效果
float factor = pow(dotP, atmPowFactor) * atmMultiplier;
// 在颜色中添加一点 dotP,使其更白,同时颜色加深
vec3 atmColor = vec3(0.35 + dotP/4.5, 0.35 + dotP/4.5, 1.0);
// 使用 atmOpacity 控制大气颜色的整体强度
gl_FragColor = vec4(atmColor, atmOpacity) * factor;
}
对于片段着色器中应用的方法,我参考了一个方法,是仅渲染该大气网格的背面,使用附加混合使其看起来透明,并从球体边缘向其中心快速增加着色。这里为什么 dotP 会向中心从 0 增加到 1,是因为我们正在渲染背面,vNormal
逐渐指向相机而不是远离相机。
atmOpacity
用于控制大气着色的不透明度,atmMultiplier
是与着色相乘的因子,可以使用它来使颜色更加强烈或不那么强烈。atmPowFactor
用于控制大气颜色变化的速度。请注意,我还将 dotP
添加到 atmColor
的r和g分量中,以便随着气氛“增强”,颜色变得更白,从而呈现更真实的色彩。
import vertexShader from "./shaders/vertex.glsl"
import fragmentShader from "./shaders/fragment.glsl"
...
const params = {
...
atmOpacity: { value: 0.7 },
atmPowFactor: { value: 4.1 },
atmMultiplier: { value: 9.5 },
}
...
let app = {
async initScene() {
...
let atmosGeo = new THREE.SphereGeometry(12.5, 64, 64)
let atmosMat = new THREE.ShaderMaterial({
vertexShader: vertexShader,
fragmentShader: fragmentShader,
uniforms: {
atmOpacity: params.atmOpacity,
atmPowFactor: params.atmPowFactor,
atmMultiplier: params.atmMultiplier
},
// 注意,默认情况下,Three.js 使用 NormalBlending,如果输出颜色的不透明度变低,显示的颜色可能会变白
blending: THREE.AdditiveBlending, // 比设置transparent:true效果更好,因为它避免了地球周围奇怪的黑暗边缘
side: THREE.BackSide // 使其不会覆盖在地球顶部;这在顶点着色器中指向相反方向的法线
})
this.atmos = new THREE.Mesh(atmosGeo, atmosMat)
this.group.add(this.atmos)
...
},
...
}
这里有很多预定义的数字,例如大气半径是12.5、atmPowFactor是4.1 、atmMultiplier是9.5 等……这里没有明确的答案,这只是我测试过的组合之一,并且效果很好。
最后一步:星空背景
背景就是插入任何你认为适合的天空盒,我这里使用等距柱状图像来实现。
const envMap = await loadTexture(GaiaSky)
envMap.mapping = THREE.EquirectangularReflectionMapping
scene.background = envMap
来看下最终效果: