作为一名前端开发笔者的第一反应不是“这个壁纸真好看”而是“这个效果我能实现吗”。这种奇特的好奇心驱使我开始了用代码复现这一视觉奇观的探索之旅。有趣的是最打动我的不是最终地球模型的逼真程度而是那个微妙的光影过渡——那条被称为“晨昏线”的光暗分界线它既清晰又模糊既分割又连接着地球的白天与黑夜。如何在代码中捕捉这种自然界的诗意过渡这个问题成为了整个项目最迷人的挑战。现在我们一起踏上这段从视觉灵感转化为技术实现的旅程我们将用Three.js绘制星辰与大海用着色器计算光影效果用数学公式模拟昼夜的交替当你看到那颗由你亲手编码的地球在浏览器中开始第一次转动时你会发现前端不仅仅是职业更是一种生活方式。环境准备要实现这样的效果我们先准备需要的一些素材贴图123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960// 背景星空球体半径constBACKGROUND_STARS_RADIUS 200;// 地球球体的半径constEARTH_RADIUS 5;// 太阳半径constSUN_RADIUS 1;// 月球半径constMOON_RADIUS 0.5// 月球轨道半径constMOON_TRACK_RADIUS EARTH_RADIUS * 2classEarth {constructor() {this.assetsLoader newAssetsLoader();this.assetsLoader.load([{type: AssetsType.Texture,name:sun,// 太阳贴图path:/images/earth/8k_sun.jpg,},{type: AssetsType.Texture,name:moon,// 月球贴图path:/images/earth/8k_moon.jpg,},{type: AssetsType.Texture,name:stars,// 星空背景贴图path:/images/earth/8k_stars_milky_way.jpg,},{type: AssetsType.Texture,name:dayTexture,// 白天贴图path:/images/earth/8k_earth_daymap.jpg,},{type: AssetsType.Texture,name:nightTexture,// 夜晚贴图path:/images/earth/8k_earth_nightmap.jpg,},{type: AssetsType.Texture,name:normalMap,// 法线贴图path:/images/earth/8k_earth_normal_map.jpg,},{type: AssetsType.Texture,name:clouds,// 云层贴图path:/images/earth/earth_clouds_2048.png,},]);this.assetsLoader.on(onLoad, () {this.initMesh();});}}本文所有素材均下载于Solar Textureshttps://www.solarsystemscope.com/textures/素材准备好后我们就可以来初始化场景下的物体我们先创造我们美丽的蓝色星球让它位于中心原点的位置12345678910111213141516171819202122classEarth {initEarth() {if(dayTexture nightTexture) {constearthMaterial newShaderMaterial({uniforms: {dayTexture: { value: dayTexture },nightTexture: { value: nightTexture },sunPosition: { value:this.sunPosition },},vertexShader: earthVertexShader,fragmentShader: earthFragmentShader,});constearthGeometry newSphereGeometry(EARTH_RADIUS, 128, 128);constearthMesh newMesh(earthGeometry, earthMaterial);earthMesh.position.set(0, 0, 0);this.basic.addScene(earthMesh);}}}然后给空旷的宇宙安装一颗恒星放在右边偏上的位置12345678910111213141516classEarth {sunPosition: Vector3 newVector3(20, 10, 0);initSun() {constsunTexture this.assetsLoader.getAssets(sun)asTexture |null;if(sunTexture) {constsunGeometry newSphereGeometry(SUN_RADIUS, 32, 32);constsunMaterial newMeshBasicMaterial({map: sunTexture,});constsun newMesh(sunGeometry, sunMaterial);sun.position.copy(this.sunPosition);this.scene.add(sun);}}}有意思的是这里的太阳虽然看起来像个发光的球但实际上它只是个“装饰品”我们真正用到的其实是它的位置信息sunPosition用于在后面模拟昼夜交替时将太阳的位置信息传入到着色器代码中。在真实宇宙中是地球绕着太阳转。但在我们的虚拟场景中为了保持地球始终在画面中央坐标原点笔者耍了个小聪明——让太阳“绕着”地球转同时给太阳一个自转。123456789101112131415161718192021classEarth {rotateVector3ByRadian(vec3: Vector3, axis: Vector3, radian: number) {// 创建旋转矩阵constmatrix newMatrix4()// 设置绕轴旋转的矩阵matrix.makeRotationAxis(axis.normalize(), radian)// 应用旋转矩阵到向量vec3.applyMatrix4(matrix)}render(clock: Clock) {rotateVector3ByRadian(this.sunPosition,newVector3(0, 1, 0),0.0004,)if(this.sunMesh) {this.sunMesh.position.copy(sunPos)this.sunMesh.rotation.y 0.002}}}接着给我们的宇宙增加一份浩瀚感这里的星空背景通过球体加上贴图来进行渲染12345678910111213141516171819classEarth {initStarBackground() {conststarsTexture this.assetsLoader.getAssets(stars)asTexture |null;if(starsTexture) {constsphereGeometry newSphereGeometry(BACKGROUND_STARS_RADIUS,64,64);sphereGeometry.scale(-1, 1, 1);constsphereMaterial newMeshBasicMaterial({map: starsTexture,side: DoubleSide,});constsphere newMesh(sphereGeometry, sphereMaterial);this.scene.add(sphere);}}}地球怎么能独自在宇宙中流浪呢怎么能少得了它忠实的小跟班——月球呢但只是放个月球太普通了我决定给它加个专属的“跑道”track1234567891011121314151617181920212223242526classEarth {moonPosition: Vector3 newVector3(0, MOON_TRACK_RADIUS, 0)initMoon() {constgroupnewGroup()consttrackGeo newTorusGeometry(MOON_TRACK_RADIUS, 0.01, 64, 64)consttrackMt newMeshBasicMaterial({color: guiOption.moon.trackColor,transparent:true,opacity: 0.5,})consttrack newMesh(trackGeo, trackMt)group.add(track)constmoonGeo newSphereGeometry(MOON_RADIUS, 64, 64)constmoonMt newMeshBasicMaterial({map: moonTexture,})constmoon newMesh(moonGeo, moonMt)moon.position.copy(this.moonPosition)group.add(moon)group.rotateX(MathUtils.degToRad(100))this.scene.add(group)}}那个半透明的轨道环其实是个视觉引导——它告诉用户“嘿月球是沿着这条路径运动的”。虽然真实月球没有可见轨道但这个设计增加了场景的科技感和可读性。当我看到月球带着它的光环开始绕着地球旋转时那感觉就像完成了一个精密的宇宙钟表——每个部件都有它的位置每个运动都有它的规律。这个小跟班让我们的地球不再孤单整个太阳系开始有了“系统”的感觉。实现昼夜分明前面我们搭建好了整个太阳系舞台但此刻的地球还只是一个静止的球体没有光影变化没有昼夜交替。现在是时候为这颗蓝色星球注入灵魂了。还记得我们初始化地球时预留的vertexShader和fragmentShader吗那两个看似简单的GLSL代码文件才是实现昼夜交替魔法的核心所在。如果说地球模型是个巨大的工厂那么顶点着色器就是为每个工人像素点准备工牌的生产线。下面着色器代码其实在做两件重要的事情12345678910// 纹理坐标varying vec2 vUv;// 变换后的法线向量varying vec3 vNormal;voidmain(){vUvuv;vNormalnormalize(normalMatrix*normal);gl_Position projectionMatrix * modelViewMatrix * vec4(position, 1.0);}vUv用来记录每个顶点的纹理坐标vNormal计算并传递法线向量每个顶点的法线就像一根小指针直直地指向该点的“正上方”normalize()函数让法线向量保持长度为1归一化。varying表示把顶点着色器的计算结果传递给下面的片元着色器。所以别看上面这段代码短它可是整个昼夜效果的地基。它为地球表面每个点都准备好了“我是谁uv坐标”、“我面朝哪里法线”就等着片元着色器来判断“你现在应该是白天还是黑夜”下面是我们最重要的片元着色器代码了12345678910111213141516171819202122#ifdef GL_ESprecision mediumpfloat;#endifuniform sampler2D dayTexture;uniform sampler2D nightTexture;uniform vec3 sunPosition;varying vec2 vUv;varying vec3 vNormal;voidmain(){vec3 lightDirnormalize(sunPosition);floatdotProductdot(normalize(vNormal),lightDir);if(dotProduct0.){gl_FragColortexture2D(dayTexture,vUv);}else{gl_FragColortexture2D(nightTexture,vUv);}}这段代码虽然只有短短的十几行却决定了地球表面每一处是光明还是黑暗。GLSL语法比较难懂我们下面就来详细介绍一下。首先我们将前面顶点着色器中处理好的单位法线向量vNormal接收然后将传入的太阳的位置接收通过normalize函数进行归一化操作得到了太阳的方向最轴将上面计算的法线向量和太阳的方向进行点积运算。将太阳位置归一化后的方向向量表示从原点指向太阳位置的单位向量而不是从太阳位置出发指向原点这一点需要注意。这里详细说一下点积运算的几何意义在三维空间中两个向量的点积公式是1dot(A, B) |A| × |B| × cos(θ)其中θ是A和B之间的夹角而我们上面已经对两个向量都进行了归一化操作因此公式简化为1dot(A, B) cos(θ)因此这里的角度θ其实代表了法线与太阳光线方向的夹角我们通过一张图来理解球体表面的蓝色箭头表示法线而原点黄色的箭头表示太阳的方向因此在上面片元着色器代码中计算得到的dotProduct变量其实也是cos(θ)的值我们通过对它的值进行判断如果dotProduct大于0则表示法线与太阳方向夹角小于90度则表示当前点在白天则使用白天贴图进行渲染否则使用夜晚贴图进行渲染运行后我们就能看到地球的白天黑夜有明显的界线分隔了。最后在太阳转动的同时不要忘记更新地球材质的uniforms中的sunPosition属性1234567classEarth {render(clock: Clock) {if(this.earthMaterial) {this.earthMaterial.uniforms.sunPosition.value.copy(this.sunPosition)}}}星空顶如果你最近去过高端楼盘展厅或者坐过某些豪华车型大概率见过那个让人惊艳的设计——星空顶。无数光点在头顶缓缓闪烁像是把整个银河系微缩在了方寸之间。这种将宇宙浪漫融入空间的设计早已成为“高端感”的代名词。我们项目怎么能少了这样迷人的星空呢不行我们的项目也要向高端、豪华看齐。虽然之前我们用一张星空贴图作为背景但是总感觉不够真实缺少了星空那种忽明忽暗的光亮效果我们在太阳到星空背景球体之间通过Points来添加众多的星星我们首先初始化星星的一些参数123456789101112131415161718192021// 星星数量constSTARS_AMOUNT 1000;// 星星最小距离constSTARS_MIN_DISTANCE 100;// 星星最大距离constSTARS_MAX_DISTANCE 200;classEarth {initStars() {conststarGeometry newBufferGeometry();// 每个点的xyz坐标constpositions newFloat32Array(STARS_AMOUNT * 3);// 每个点rgb颜色constcolors newFloat32Array(STARS_AMOUNT * 3);// 每个点的初始大小constsizes newFloat32Array(STARS_AMOUNT);// 每个点的闪烁相位constphases newFloat32Array(STARS_AMOUNT);// 每个点的闪烁频率constfrequencies newFloat32Array(STARS_AMOUNT);}}由于我们想要生成从太阳到星空背景球体之间圆环内的随机点因此我们可以通过极坐标的方式来计算通过极坐标转换到三维空间内的坐标1234567891011121314151617classEarth {initStars() {for(leti 0; i STARS_AMOUNT; i) {consti3 i * 3// 在球体空间内随机生成位置constdistance getRandomInt(STARS_MIN_DISTANCE, STARS_MAX_DISTANCE)consttheta Math.random() * Math.PI * 2// 方位角constphi Math.acos(2 * Math.random() - 1)// 极角// 球坐标转直角坐标positions[i3] distance * Math.sin(phi) * Math.cos(theta)positions[i3 1] distance * Math.sin(phi) * Math.sin(theta)positions[i3 2] distance * Math.cos(phi)}}}坐标位置搞定了我们继续给每个点生成随即的颜色、大小、闪烁相位、闪烁频率属性12345678910111213141516171819202122232425// 随机颜色偏向白色和蓝色constcolorChoice Math.random()if(colorChoice 0.7) {// 白色/淡黄色星星colors[i3] 1.0// Rcolors[i3 1] 0.9 Math.random() * 0.1// Gcolors[i3 2] 0.8 Math.random() * 0.2// B}elseif(colorChoice 0.9) {// 蓝色星星colors[i3] 0.4 Math.random() * 0.3// Rcolors[i3 1] 0.6 Math.random() * 0.3// Gcolors[i3 2] 1.0// B}else{// 红色/橙色星星colors[i3] 1.0// Rcolors[i3 1] 0.5 Math.random() * 0.3// Gcolors[i3 2] 0.3 Math.random() * 0.2// B}// 大小sizes[i] Math.random() * 2 0.5// 闪烁频率frequencies[i] Math.random() * 0.5 0.5// 闪烁相位phases[i] Math.random() * Math.PI * 2最后我们通过BufferGeometry将这些数据传入Points对象中12345starGeometry.setAttribute(position,newBufferAttribute(positions, 3))starGeometry.setAttribute(color,newBufferAttribute(colors, 3))starGeometry.setAttribute(size,newBufferAttribute(sizes, 1))starGeometry.setAttribute(phase,newBufferAttribute(phases, 1))starGeometry.setAttribute(frequency,newBufferAttribute(frequencies, 1))然后创建Points对象同时在uniforms中添加一个time属性用于控制星星闪烁12345678910conststarMaterial newShaderMaterial({uniforms: {time: { value: 0.0 },},vertexShader: starsVertexShader,fragmentShader: starsFragmentShader,transparent:true,blending: AdditiveBlending,})conststars newPoints(starGeometry, starMaterial)在我们的顶点着色器代码中接收上面的顶点数据12345678910111213141516171819202122232425attributefloatsize;attribute vec3 color;attributefloatphase;attributefloatfrequency;varying vec3 vColor;uniformfloattime;voidmain() {vColor color;// 闪烁效果计算floatblink sin(time * frequency phase) * 0.5 0.8;// 添加一些随机噪声使闪烁更自然floatnoise sin(dot(position, vec3(12.9898, 78.233, 45.5432)) * 43758.5453) * 0.1;// 最终大小floatfinalSize size * (blink noise);vec4 mvPosition modelViewMatrix * vec4(position, 1.0);gl_PointSize finalSize * (300.0 / -mvPosition.z);gl_Position projectionMatrix * mvPosition;}vColor用来将前面生成的点的颜色传递给片元着色器让星星有独立的颜色blink是实现星星闪烁的关键公式time × frequency表示随时间变化的相位phase控制每个粒子的初始相位偏移使得闪烁不会同步进行sin函数产生平滑的正弦波振荡取值范围是[-1, 1]最终blink的范围是[0.3, 1.3]再乘以size初始化大小得到了星星在不同时刻的最终大小。最后片元着色器代码如下123456789101112131415161718#ifdef GL_ESprecision mediumpfloat;#endifvarying vec3 vColor;voidmain() {// 圆形点floatdistanceToCenter length(gl_PointCoord - vec2(0.5));if(distanceToCenter 0.5) {discard;}// 添加一些发光效果floatalpha 1.0 - smoothstep(0.0, 0.5, distanceToCenter);gl_FragColor vec4(vColor, alpha * 0.9);}美丽的晨昏线如果仔细观察真实的地球照片你会发现一个迷人的细节白天和黑夜之间并没有一条生硬的分界线。取而代之的是一片温柔过渡的“灰色地带”——这就是我们常说的晨昏线也是日出日落时分最富诗意的区域。回头看我们之前实现的昼夜分割的效果虽然功能完整但是少了些自然界的柔美现实世界的光影变化从来不是非黑即白的开关而是渐变的艺术下面我们就来创造出一个平滑过渡的晨昏区域让白昼缓缓融入黑夜。上面我们详细介绍了白天黑夜如何通过太阳光和法线进行判断而其核心原理就是下面的计算公式1floatdotProductdot(normalize(vNormal),lightDir);dotProduct的取值范围是[-1, 1]当在01之间时代表表面正对太阳是白天-10之间表示背对太阳是黑夜我们想要让太阳在中间地带有一个过渡的范围我们先给ShaderMaterial传入一个参数transitionWidth12345678910constearthMaterial newShaderMaterial({uniforms: {dayTexture: { value: dayTexture },nightTexture: { value: nightTexture },// 新增过渡范围参数transitionWidth: { value: 0.2 },},vertexShader: earthVertexShader,fragmentShader: earthFragmentShader,})然后在片元着色器中添加过渡参数123456uniformfloattransitionWidth;voidmain(){floattransitionCenter 0.0;// 晨昏线floattransitionStart transitionCenter - transitionWidth * 0.5;floattransitionEnd transitionCenter transitionWidth * 0.5;}当传入transitionWidth是0.2时计算得到下面的范围transitionStart -0.1transitionEnd 0.1这就意味着在dotProduct点积值[-0.1, 0.1]范围内是过渡区域然后使用smoothstep创建一个平滑插值1234voidmain(){// 使用smoothstep创建平滑过渡floatmixFactor smoothstep(transitionStart, transitionEnd, dotProduct);}smoothstep函数的行为如下当 dotProduct transitionStart 时mixFactor 0.0当 dotProduct transitionEnd 时mixFactor 1.0当 transitionStart dotProduct transitionEnd 时mixFactor 平滑过渡因此smoothstep函数实际上将dotProduct区间值[-1, 1]映射到mixFactor的[0, 1]范围内并创建一个平滑过渡其中[-1 , -0.1]映射为0,表示黑夜[0.1 , 1]映射为1表示白天中间的(-0.1 , 0.1)映射到(0, 1)表示过渡的区域我们通过一个表格来详细表示最后使用mix函数将白天和黑夜的纹理进行混合