为 R3F/three.js 行星实现稳健的日/夜效果:叠加夜光壳 + 基底变暗 + 云层夜侧减弱

用独立 ShaderMaterial 叠加夜光壳、标准材质 onBeforeCompile 基底夜侧变暗、云层材质夜侧减弱,替代易碎的 onBeforeCompile 双贴图混合。并修复 ShaderMaterial 顶点着色器 objectNormal 导致的编译报错。

September 20, 2025 (2w ago)
10 min read
1,058 views
240 likes
Category
Intermediate
#threejs#react-three-fiber#shader#webgl#debugging#r3f#graphics

为 R3F/three.js 行星实现稳健的日/夜效果

在实现地球的日/夜切换与城市夜光时,纯粹依赖 MeshStandardMaterialonBeforeCompile 来“直接混合两张贴图(白天/夜晚)”很容易踩到宏与 varyings 的坑,在不同组合(是否 USE_MAP、是否使用 emissive、内置 chunk 变化)下不稳。这次我采用了更可靠的分层方案:

  • 叠加夜光壳(ShaderMaterial + Additive):仅在夜侧显示城市灯光贴图,独立渲染次序与深度策略,避免被云层/深度相互影响。
  • 基底行星夜侧变暗:在标准材质的 onBeforeCompile 里仅做“日/夜因子”乘法,压低夜侧 albedo,抵消环境光/IBL 残留亮度。
  • 云层夜侧减弱:云层材质 patch,夜侧降低 opacity 与亮度,保证夜光清晰可见。
  • 光源位置统一与联动:将场景方向光的位置作为 uniform 传入三处材质,每帧同步,确保明暗边界一致。

文件路径:src/components/goals-showcase/GoalsScene.tsx,组件:TexturedPlanet


1. 叠加夜光壳(稳定可控)

  • 独立的 <mesh> + <shaderMaterial>,包裹在行星表面之外(缩放系数 1.001),并且:
    • blending=AdditiveBlending
    • depthTest=falsedepthWrite=false
    • renderOrder=12(云层是 10
  • 顶点着色器输出 vWorldPosvWorldNormal;片元里基于 dot(N, L) 计算夜侧掩码:
float ndl = max(0.0, dot(normalize(vWorldNormal), L));
float nightF = 1.0 - smoothstep(0.2, 0.9, ndl);
vec3 col = texture2D(uNightMap, vUv).rgb * nightF * uBoost;
  • 每帧与行星保持一致的缩放与自转,使灯光纹理与大陆对齐;亮度可通过 uBoost 进行整体增强(当前 2.0,可按需加大)。

这样做的好处是将夜光“从标准材质里解耦”出来,绘制顺序、深度策略、显隐逻辑都可独立控制,避免 onBeforeCompile 混合时的易碎性


2. 行星基底:仅做夜侧变暗

  • 使用 onBeforeCompile 注入每像素的“日/夜因子”,对 diffuseColor.rgb 做乘法而非贴图混合:
vec3 L = normalize(uLightPosWorld - vWorldPosition);
float ndl = max(0.0, dot(normalize(vWorldNormal), L));
float day = smoothstep(0.2, 0.9, ndl);
diffuseColor.rgb *= (0.18 + 0.82 * day); // 夜侧更暗,白天保持
  • 这样即便在有环境光/HDRI 的场景中,夜侧也不会“灰不溜秋”,为上面的夜光叠加提供对比度

3. 云层:夜侧降低不透明度与亮度

  • 云层同样通过 onBeforeCompile 获取 vWorldPos/vWorldNormaluLightPosWorld,并在 #include <map_fragment> 后调整:
float day = smoothstep(0.2, 0.9, ndl);
diffuseColor.a  *= (0.15 + 0.85 * day); // 夜侧更薄
diffuseColor.rgb *= (0.40 + 0.60 * day); // 夜侧更暗
  • 这能显著避免云层把夜光遮住,同时保留白天的体感与层次。

4. 光源位置统一与联动

  • Planet 层向 TexturedPlanet 传入 lightPos={[4,6,4]},并将其作为 uniform 同步给:
    • 基底行星材质(夜侧变暗的 uLightPosWorld
    • 云层材质(同上)
    • 夜光叠加壳(用于计算夜侧掩码)
  • useFrame 每帧更新这三个材质的 uniform,确保明暗边界一致

5. 编译错误的根因与修复

在夜光叠加的 ShaderMaterial 顶点着色器中,我最初写成了:

vWorldNormal = normalize(mat3(modelMatrix) * objectNormal);

objectNormal 仅在 three.js 内置标准材质的 shader chunk(#include <beginnormal_vertex>)里被定义,自定义 ShaderMaterial 并不具备此变量,导致:

THREE.WebGLProgram: Shader Error 0 - VALIDATE_STATUS false / Vertex shader is not compiled.

修复方式:改用几何体属性 normal(存在于 sphereGeometry 顶点数据中):

vWorldNormal = normalize(mat3(modelMatrix) * normal);

改动后编译通过,渲染正常。


6. 验证清单

  • 渲染顺序:夜光叠加 renderOrder=12,云层 10
  • 深度策略:夜光叠加 depthTest=falsedepthWrite=false
  • 对齐一致性:自转时夜光与大陆保持贴合。
  • 环境光:如需更强对比,可将 <ambientLight intensity={0.35}> 降至 0.25~0.20
  • 亮度参数:uBoost 提升到 3.0~4.0 可获得更强烈的夜景效果。

7. 可选增强(下一步)

  • 添加 debugMask 开关:可视化 nightF 掩码,方便校对光照方向与阈值。
  • 提供运行时控制(UI/热键):调节 uBoost、云层夜侧权重、基底夜侧暗度。
  • 多行星扩展:将纹理与参数抽象为配置,支持不同的 albedo/night/clouds 组合。
  • 性能:移动端或低端设备降低 DPR、减少云层细分数、降采样夜光贴图。

8. 参考与致谢

  • three.js Journey(Earth Shaders 课程)
  • StackOverflow:夜光贴图按 dot(N,L) 掩码叠加
  • three.js onBeforeCompile 常见坑:varyings/uniforms 与宏的兼容性

若你也在做 R3F + three.js 的行星日/夜效果,推荐优先采用“叠加夜光壳 + 基底变暗 + 云层减弱”的分层方式,稳定、省心、易调试

CleanLove

Written by CleanLove

Frontend engineer exploring delightful motion and robust UX

Initializing application
Loading page content, please wait...