Three.js案例
Earth

地球大气光晕效果

一、简介

今天来看下官网的案例,通过three/tsl实现地球黑夜白天的的大气光晕效果。

在这个示例中,利用了 Three.js 最新的 WebGPURenderer 来渲染地球表面及其大气层光晕效果。相较于传统的 WebGLRenderer,WebGPU 在新硬件和现代浏览器环境下能够提供更高效的渲染性能。

同时,采用 Three.js 的「节点材质系统」(NodeMaterial + three/tsl) 来实现自定义的着色逻辑,包括大气光晕、地表白天与夜晚纹理切换、法线凹凸模拟等效果。

访问地址:https://threejs.org/examples/#webgpu_tsl_earth (opens in a new tab)

二、HTML 与基础结构

<script> 模块导入配置(importmap),之所以要做 importmap,是为了告诉浏览器如何解析从 three/addons/ 等位置导入的模块,以实现模块化加载。

在这段代码里,需特别注意以下几点实现原理:

  1. <script type="importmap"> 中的配置项指定了 threethree/webgputhree/tsl 等别名对应的实际路径,减少在后续脚本中写长路径的麻烦。
  2. <script type="module"> 中编写示例主逻辑,这也是现代前端模块化的标准做法。

下面是对应的代码块:

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>three.js webgpu - earth</title>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0" />
    <link type="text/css" rel="stylesheet" href="main.css" />
  </head>
  <body>
    <script type="importmap">
      {
        "imports": {
          "three": "../build/three.webgpu.js",
          "three/webgpu": "../build/three.webgpu.js",
          "three/tsl": "../build/three.tsl.js",
          "three/addons/": "./jsm/"
        }
      }
    </script>
  </body>
</html>

到这里,HTML 的头部和导入配置就基本完成。接下来,我们在 <script type="module"> 中开始核心的 Three.js 逻辑。

三、初始化:相机、场景、时钟

在以下代码块中,先导入 Three.js、OrbitControlsGUI 等必要模块,并声明了全局变量 camerascenerenderercontrolsglobeclock 以便在后续逻辑中使用。然后,在 init() 函数里进行相机和场景的初始化。这里之所以要将所有初始化放在一个函数中,是为了在页面加载后可直接调用 init(),使代码结构更清晰。

具体要点如下:

  1. camera = new THREE.PerspectiveCamera( 25, window.innerWidth / window.innerHeight, 0.1, 100 );:使用透视相机并设置了较小的近截面 0.1 和远截面 100,以便能看清近处细节又兼顾一定远景范围。
  2. camera.position.set( 4.5, 2, 3 );:将相机放置在略高并偏向侧面的角度,更好地展示地球和大气层。
  3. scene = new THREE.Scene();:新建一个场景容器,让后续的光源、模型和材质都在这里进行管理。
  4. clock = new THREE.Clock();:创建一个时钟对象,用于在动画循环中获取帧与帧之间的时间间隔,为地球自转或其它时间相关效果提供依据。
  5. 之所以先创建相机和场景,是因为它们是后续添加光源与地球 Mesh 的前置条件,能让我们在后面把对象添加到场景中并用相机去观察。
<script type="module">
 
	import * as THREE from 'three';
	import { step, normalWorld, output, texture, vec3, vec4, normalize, positionWorld, bumpMap, cameraPosition, color, uniform, mix, uv, max } from 'three/tsl';
 
	import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
	import { GUI } from 'three/addons/libs/lil-gui.module.min.js';
 
	let camera, scene, renderer, controls, globe, clock;
 
	init();
 
	function init() {
 
		clock = new THREE.Clock();
 
		camera = new THREE.PerspectiveCamera( 25, window.innerWidth / window.innerHeight, 0.1, 100 );
		camera.position.set( 4.5, 2, 3 );
 
		scene = new THREE.Scene();

四、创建光源:DirectionalLight

地球表面颜色之所以能区分昼夜,并呈现出大气光晕效果,离不开太阳光的存在。这里我们使用 DirectionalLight 来模拟太阳光线。DirectionalLight 让所有平行射线从指定方向照向场景中的物体,能够产生类似太阳光的均匀照射效果。

在以下代码块中,我们可以看到:

  1. const sun = new THREE.DirectionalLight( '#ffffff', 2 );:指定为白色光,强度为 2,稍微高于默认值,以便突显昼夜区域的差异。
  2. sun.position.set( 0, 0, 3 );:让光源出现在场景中 z 轴 3 的位置,相当于地球前方正面。
  3. scene.add( sun );:把这个平行光添加到场景,才能对后续地球表面产生影响。
  4. 在此示例中,没有使用额外的环境光或点光源,尽量模拟只有一个太阳照射的效果,让昼夜分界线更明显。
  5. 选择 DirectionalLight 而非 PointLightSpotLight,是因为地球与太阳之间的距离巨大,几乎可以视为平行光线,这与现实中的太阳光源最相似。
// sun
 
const sun = new THREE.DirectionalLight("#ffffff", 2);
sun.position.set(0, 0, 3);
scene.add(sun);

这样,场景中就有了一个模拟太阳的光源。后续在着色器中,我们会计算地球表面与光线方向的点积,用于生成昼夜区域和大气颜色的渐变。


五、定义自定义着色所需的 Uniforms

为了让地球的昼夜、晨昏与大气光晕在材质中生动变化,我们需要在 Shader 中使用到一些可调参数(uniform),这些参数在 NodeMaterial 中会被动态引用。接下来这段代码中,我们定义了 atmosphereDayColoratmosphereTwilightColorroughnessLowroughnessHigh 等关键变量,并给出了它们的默认值:

  1. atmosphereDayColor:当阳光正面照射时,大气层呈现的颜色,默认是偏蓝 (#4db2ff),模拟晴空下的蓝色天光。
  2. atmosphereTwilightColor:当处于晨昏或较低光照角度时,大气层接近橘红 (#bc490b),表现黎明或黄昏的暖色调。
  3. roughnessLow / roughnessHigh:地表粗糙度范围,用来在昼夜或云层区域进行差异化的表面反射。

之所以采用 uniform( color( ... ) )uniform( 0.25 ) 这样的写法,是因为我们使用了 three/tsl 的节点系统,能自动把这些数值映射为可在材质着色器中使用的 uniform。

// uniforms
 
const atmosphereDayColor = uniform(color("#4db2ff"));
const atmosphereTwilightColor = uniform(color("#bc490b"));
const roughnessLow = uniform(0.25);
const roughnessHigh = uniform(0.35);

这样做的好处在于,我们可以在代码或 GUI 中实时调整这些值,而不必改动主 Shader 逻辑,节点材质会自动接收并使用更新后的参数,从而使开发和调试更加灵活。

六、加载地球纹理

为了让地球表面既有白天纹理、又有夜晚城市灯光纹理,还需要 bump、roughness、clouds 等贴图,本示例中使用了来自 Solar System Scope 的高分辨率地球贴图。在下方代码中,我们通过 TextureLoader 逐张加载这些贴图,并设置其色彩空间和各向异性(anisotropy)等参数。

需要重点理解:

  1. dayTexture:地球白天表面纹理(陆地、海洋颜色)。
  2. nightTexture:地球夜晚纹理,包含城市夜灯分布。
  3. bumpRoughnessCloudsTexture:将地表凹凸、粗糙度以及云层信息打包在同一张图中,通过不同的通道(R/G/B)来分别表示高度、粗糙度和云层分布。
  4. textureLoader.load:是三.js 中最常见的加载方法,返回 Texture 对象,用来设置重复模式、色彩空间、各向异性等特性。
  5. texture.colorSpace = THREE.SRGBColorSpace;:指定纹理使用 sRGB 颜色空间,以便在物理着色流程中获得准确的色彩。
// textures
 
const textureLoader = new THREE.TextureLoader();
 
const dayTexture = textureLoader.load("./textures/planets/earth_day_4096.jpg");
dayTexture.colorSpace = THREE.SRGBColorSpace;
dayTexture.anisotropy = 8;
 
const nightTexture = textureLoader.load("./textures/planets/earth_night_4096.jpg");
nightTexture.colorSpace = THREE.SRGBColorSpace;
nightTexture.anisotropy = 8;
 
const bumpRoughnessCloudsTexture = textureLoader.load("./textures/planets/earth_bump_roughness_clouds_4096.jpg");
bumpRoughnessCloudsTexture.anisotropy = 8;

通过这些贴图,我们等下就能在 Node 材质里分别调用纹理通道,去控制地球在哪些部分有白天或夜晚,以及云层叠加、地形凹凸等效果,这也是整个地球渲染的重要基础。

七、Fresnel、光照方向与昼夜渐变

下面这段代码是示例的核心之一:我们先计算 Fresnel 效应,以便在观察视角与表面法线夹角较小时产生大气渐变;然后计算地球表面接收到的太阳光方向点积,用来区分昼夜区域以及过渡时的大气颜色。

Fresnel 计算

  1. const viewDirection = positionWorld.sub( cameraPosition ).normalize();:获取世界空间下顶点到相机的方向向量。
  2. const fresnel = viewDirection.dot( normalWorld ).abs().oneMinus().toVar();:视角方向与法线方向的点积越大,说明入射角小,Fresnel 效应越弱;反之则越强,所以用 1 - dot 做了一个反转。

光照方向 (sunOrientation)

  1. const sunOrientation = normalWorld.dot( normalize( sun.position ) ).toVar();:地球表面的法线与太阳光方向做点积,点积大于 0 说明面向太阳,为白天;数值靠近负则是背光面,为夜晚。
  2. 后续会对 sunOrientation 做一次 smoothstep(-0.25, 0.75) 之类的平滑处理,让昼夜分界线过渡自然。

Atmosphere 颜色

  1. 使用 mix( atmosphereTwilightColor, atmosphereDayColor, sunOrientation.smoothstep(...) ) 根据太阳角度混合白天蓝色和晨昏橙色。
  2. 大气其实也受 Fresnel 影响,所以会在最后与 Fresnel 计算相结合,形成朝向边缘时更明显的大气光晕。
// fresnel
 
const viewDirection = positionWorld.sub(cameraPosition).normalize();
const fresnel = viewDirection.dot(normalWorld).abs().oneMinus().toVar();
 
// sun orientation
 
const sunOrientation = normalWorld.dot(normalize(sun.position)).toVar();
 
// atmosphere color
 
const atmosphereColor = mix(atmosphereTwilightColor, atmosphereDayColor, sunOrientation.smoothstep(-0.25, 0.75));

这种写法充分体现了 three/tsl 的节点式编程思路:我们将世界坐标、法线、光照方向、摄像机位置等信息作为输入,通过点积、混合、取绝对值、平滑插值等操作组合在一起,最终得到 Fresnel 效应和昼夜过渡所需的数值。这些数值会在后续赋予到 MeshStandardNodeMaterial 中,从而实时影响渲染结果。

八、地球表面 NodeMaterial 构建

接下来便是最核心的「地球表面」材质构建。通过 MeshStandardNodeMaterial() 我们能获得带有 PBR 特性的基本材质,然后在此基础上用节点操作生成最终的 colorNoderoughnessNodenormalNode 等。最后将结果混合成 outputNode

  1. const globeMaterial = new THREE.MeshStandardNodeMaterial();:这是一个基于节点系统的 PBR 材质,具备金属度、粗糙度、法线等多种属性。
  2. cloudsStrength = texture( bumpRoughnessCloudsTexture, uv() ).b.smoothstep( 0.2, 1 );:从云图的蓝色通道中提取数据,通过 smoothstep 筛选出云层分布区域。
  3. globeMaterial.colorNode = mix( texture( dayTexture ), vec3( 1 ), cloudsStrength.mul( 2 ) );:混合白天贴图与纯白色,只有云层区域会被叠加到偏白的效果。
  4. const roughness = max( texture( bumpRoughnessCloudsTexture ).g, step( 0.01, cloudsStrength ) );:从绿通道获取粗糙度信息,并与云层强度取一个最大值,使云层部分看起来更粗糙。
  5. night = texture( nightTexture )dayStrength = sunOrientation.smoothstep( -0.25, 0.5 ) 结合:如果 dayStrength 数值偏低,说明背光面更偏向夜晚纹理。
  6. 最终的 globeMaterial.outputNode = vec4( finalOutput, output.a ); 会整合夜晚贴图、日照强度、大气颜色等要素,使地表昼夜分明、云层显现。

下面是这段关键代码:

// globe
 
const globeMaterial = new THREE.MeshStandardNodeMaterial();
 
const cloudsStrength = texture(bumpRoughnessCloudsTexture, uv()).b.smoothstep(0.2, 1);
 
globeMaterial.colorNode = mix(texture(dayTexture), vec3(1), cloudsStrength.mul(2));
 
const roughness = max(texture(bumpRoughnessCloudsTexture).g, step(0.01, cloudsStrength));
globeMaterial.roughnessNode = roughness.remap(0, 1, roughnessLow, roughnessHigh);
 
const night = texture(nightTexture);
const dayStrength = sunOrientation.smoothstep(-0.25, 0.5);
 
const atmosphereDayStrength = sunOrientation.smoothstep(-0.5, 1);
const atmosphereMix = atmosphereDayStrength.mul(fresnel.pow(2)).clamp(0, 1);
 
let finalOutput = mix(night.rgb, output.rgb, dayStrength);
finalOutput = mix(finalOutput, atmosphereColor, atmosphereMix);
 
globeMaterial.outputNode = vec4(finalOutput, output.a);
 
const bumpElevation = max(texture(bumpRoughnessCloudsTexture).r, cloudsStrength);
globeMaterial.normalNode = bumpMap(bumpElevation);
 
const sphereGeometry = new THREE.SphereGeometry(1, 64, 64);
globe = new THREE.Mesh(sphereGeometry, globeMaterial);
scene.add(globe);

值得关注的是 globeMaterial.normalNode = bumpMap( bumpElevation ); 这一行,它将红色通道(R 通道)中的凹凸数据与云层区域结合,来模拟表面法线扰动,使高山、海洋和云层在光照下更有层次感。这种将多种贴图数据合并在一张纹理里的做法可以提高加载效率,但需要用到不同通道分别读取。

最后,我们将这个地球球体添加到场景,同时配置几何体的细分为 64 × 64,足够让凹凸和大气边缘在视野中看起来细腻。

九、大气层外壳 Mesh

为了让地球外部的大气光晕更明显,我们还创建了一个比地球略大的球体,使用 MeshBasicNodeMaterial 并启用 BackSide 双面渲染,让它只在外壳内部可见。通过 Fresnel 以及与太阳光的点积,可以在球体边缘产生那种淡蓝色或橘红色渐变。

  1. const atmosphereMaterial = new THREE.MeshBasicNodeMaterial( { side: THREE.BackSide, transparent: true } );:MeshBasic 表示不会受到额外光照影响,BackSide 让我们只看到球体内部部分,transparent: true 用于实现半透明光晕。
  2. let alpha = fresnel.remap( 0.73, 1, 1, 0 ).pow( 3 );:Fresnel 进行了一次 remappow(3),使得在球体边缘处才渐渐显现大气。
  3. alpha = alpha.mul( sunOrientation.smoothstep( - 0.5, 1 ) );:结合太阳角度,只有在面向太阳的半球才会显得亮一些。
  4. 最终将 atmosphereMaterial.outputNode = vec4( atmosphereColor, alpha );,产生带有渐变透明度的颜色输出。
  5. 为使大气球半径略大于地球本体,我们做了 atmosphere.scale.setScalar( 1.04 );,让球壳扩展 4% 左右,营造真实的大气层包裹效果。
// atmosphere
 
const atmosphereMaterial = new THREE.MeshBasicNodeMaterial({ side: THREE.BackSide, transparent: true });
let alpha = fresnel.remap(0.73, 1, 1, 0).pow(3);
alpha = alpha.mul(sunOrientation.smoothstep(-0.5, 1));
atmosphereMaterial.outputNode = vec4(atmosphereColor, alpha);
 
const atmosphere = new THREE.Mesh(sphereGeometry, atmosphereMaterial);
atmosphere.scale.setScalar(1.04);
scene.add(atmosphere);

由于大气层不参与物理光照计算,只要对边缘 Fresnel 做出颜色和透明度控制,就能在视觉上制造出那种蓝边或橘边的光晕效果,跟地球本体形成内外两层。

十、调试 GUI

为了便于实时调节大气色调和地表粗糙度,我们引入了 lil-gui 并将先前定义的 uniform 变量与其对应起来。这样开发者或演示者可以不改动代码就能看到效果变化,极大提升了可交互性与可调试性。

  1. 创建一个 GUI 实例,随后通过 .addColor().add() 等方法把 uniform 绑定上来。
  2. 对于颜色类型的 uniform,使用 onChange 回调来动态 set() 新的颜色值。
  3. 对于数值类型则直接用 .value 调整即可。
  4. 这种调试界面有助于进一步研究地球着色的各种参数对最终渲染效果的影响,也可以在产品化时去掉 GUI。
// debug
 
const gui = new GUI();
 
gui
  .addColor({ color: atmosphereDayColor.value.getHex(THREE.SRGBColorSpace) }, "color")
  .onChange((value) => {
    atmosphereDayColor.value.set(value);
  })
  .name("atmosphereDayColor");
 
gui
  .addColor({ color: atmosphereTwilightColor.value.getHex(THREE.SRGBColorSpace) }, "color")
  .onChange((value) => {
    atmosphereTwilightColor.value.set(value);
  })
  .name("atmosphereTwilightColor");
 
gui.add(roughnessLow, "value", 0, 1, 0.001).name("roughnessLow");
gui.add(roughnessHigh, "value", 0, 1, 0.001).name("roughnessHigh");

当我们拖动某个参数滑块或更换某个颜色,就会立刻更新相应 uniform 并触发地球着色器重新计算,轻松观察不同大气颜色与粗糙度效果。

十一、WebGPURenderer 与 OrbitControls

接下来这段代码中,重点是将渲染器换成 WebGPURenderer(),并结合 OrbitControls 使我们可以在浏览器中用鼠标灵活旋转、缩放观察地球。这个部分通常是示例的基础收尾逻辑。

  1. renderer = new THREE.WebGPURenderer();:启用 WebGPU 渲染器,需要现代浏览器和硬件支持。
  2. renderer.setPixelRatio( window.devicePixelRatio );renderer.setSize( window.innerWidth, window.innerHeight );:确保渲染尺寸与屏幕像素比相匹配,保证高分辨率清晰度。
  3. renderer.setAnimationLoop( animate );:在 WebGPU 环境下,通过这种方式设置主循环,每一帧都会调用 animate()
  4. document.body.appendChild( renderer.domElement );:将渲染器的 <canvas> 加入到页面的 <body>,以便看到最终画面。
  5. controls = new OrbitControls( camera, renderer.domElement );:这是一个方便的相机控制器,允许用户在场景中自由旋转、缩放。enableDamping = true 可以带来一点惰性平滑效果。
// renderer
 
renderer = new THREE.WebGPURenderer();
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setAnimationLoop(animate);
document.body.appendChild(renderer.domElement);
 
// controls
 
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.minDistance = 0.1;
controls.maxDistance = 50;
 
window.addEventListener("resize", onWindowResize);

有了这一部分,页面就会开始持续渲染帧画面,而且我们还能通过鼠标和滚轮对相机进行旋转与缩放,从而找到最佳视角去观察昼夜交替和大气光晕。

十二、动画循环与事件监听

最后,在 animate() 函数里,我们通过获取 clock.getDelta() 来计算时间差,使地球在每一帧都作出一定的自转动画。并且在每次渲染前更新 OrbitControls 以响应用户操作。窗口大小变化时通过 onWindowResize() 调整相机投影矩阵与渲染器尺寸,防止画面变形。

具体原理如下:

  1. const delta = clock.getDelta();:获取两帧之间的时间间隔(秒)。
  2. globe.rotation.y += delta * 0.025;:让地球缓缓自转,旋转速度为 0.025(可自行调试)。
  3. controls.update();:将用户的鼠标或键盘操作变更应用到相机上。
  4. renderer.render( scene, camera );:调用渲染器渲染当前场景与相机的画面。
  5. onWindowResize() 会修正 camera.aspectrenderer.setSize(...),保证自适应窗口变化。
async function animate() {
 
	const delta = clock.getDelta();
	globe.rotation.y += delta * 0.025;
 
	controls.update();
 
	renderer.render( scene, camera );
 
}
 
function onWindowResize() {
 
	camera.aspect = window.innerWidth / window.innerHeight;
	camera.updateProjectionMatrix();
 
	renderer.setSize( window.innerWidth, window.innerHeight );
 
}
</script>
</body>
</html>

至此,完整的地球大气示例就完成了。每帧中都会根据地球与光源的相对角度、观察视角、Fresnel 与贴图信息,呈现昼夜、云层和大气的渐变效果。在 OrbitControls 的帮助下,我们可以任意旋转和观看地球表面。如果想要加快或减慢自转速度,只需微调 globe.rotation.y += delta * 0.025; 中的系数即可。

十三、总结

  1. WebGPURenderer 的优势
    使用 WebGPU 能充分利用现代显卡的新特性,理论上提升渲染效率;不过当前还处于较新阶段,要求浏览器版本足够新且硬件支持完整。

  2. NodeMaterial 的灵活性
    Three.js 的节点式编程让开发者无需直接书写 GLSL 代码,通过链式调用和拼接,就能快速实现各种自定义着色逻辑,包括 Fresnel、大气混合、昼夜贴图等。

  3. 贴图通道复用
    将地形凹凸、粗糙度、云层信息合并到一张纹理中,对性能和加载速度都更友好,也使整合和混合逻辑更简洁。但在使用前,需要明白每个通道对物理量的含义与数值范围。

  4. 昼夜过渡与大气层
    通过 sunOrientation(地表法线和太阳光方向的点积)来判断正面或背光,再用 smoothstep 做平滑过渡。大气球体利用反面渲染和 Fresnel 效应,营造包裹与氛围感。

  5. 可交互调试
    借助 lil-gui,可以方便地调整大气颜色、地表粗糙度、晨昏色调等,能迅速验证效果并进行优化,使得代码更具扩展性与可玩性。

通过以上解析,希望你已经对本示例的运行方式、着色原理、关键节点调用以及昼夜大气的数学逻辑有了全面的认识。只要按照文中说明,确认浏览器支持 WebGPU,部署到本地或线上环境,即可看到一个带有细腻昼夜过渡与柔和大气光晕的三维地球。祝你在 Three.js + WebGPU 的世界里玩得愉快,探索更精彩的可视化和着色器技术!

代码

https://github.com/mrdoob/three.js/blob/master/examples/webgpu_tsl_earth.html (opens in a new tab)