为 R3F/three.js 行星实现稳健的日/夜效果
在实现地球的日/夜切换与城市夜光时,纯粹依赖 MeshStandardMaterial
的 onBeforeCompile
来“直接混合两张贴图(白天/夜晚)”很容易踩到宏与 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=false
、depthWrite=false
renderOrder=12
(云层是10
)
- 顶点着色器输出
vWorldPos
与vWorldNormal
;片元里基于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/vWorldNormal
与uLightPosWorld
,并在#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=false
、depthWrite=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 的行星日/夜效果,推荐优先采用“叠加夜光壳 + 基底变暗 + 云层减弱”的分层方式,稳定、省心、易调试。