从一片天空到可交付插件:Godot Stylized TOD Sky v0.3 攻坚实录
本文记录了一个 Godot 4 风格化昼夜系统从项目内脚本逐步成长为通用插件的过程。它并非针对某款特定游戏设计的天空,而是面向游戏开发者与技术美术的可复用工具。本文撰写于 v0.3 阶段,重点不在于性能优化或多平台兼容性,而在于功能边界、技术美术质量,以及那些唯有在真实画面中才会暴露出来的问题。

当前 Demo:时间轴、五套气候 Profile、自动时间流动与 0-100 倍时间缩放。测试建筑只是尺度和光照参照,不属于插件运行依赖。
一、它为什么会从“项目功能”变成“插件”
很多昼夜系统的第一版都很相似:一个脚本保存当前小时,按时间旋转方向光,再顺便改一下天空颜色。它足以完成一次演示,却很难进入下一款游戏。
真正开始复用时,问题会迅速出现:
- 场景里已经有
WorldEnvironment和主方向光,插件是否有权接管? - 日出、正午、黄昏和深夜之间,是连续采样还是硬编码分支?
- 阴天、海岸和寒冷气候,是复制五份控制器,还是共享一套时间结构?
- 编辑器预览修改了环境资源,退出预览后能否完整恢复?
- 云是天空 Shader 中的一张平铺贴图,还是能独立表现视差、生命周期与受光方向的对象?
- 时间倍率改变后,太阳、云层和 HUD 是否仍然处于同一时间语义?
这些问题共同指向一个结论:昼夜系统不应该只是一个“会转太阳的脚本”,而应该是一个有明确数据模型、资源所有权和表现层边界的插件。
Stylized TOD Sky 最初受 AHD2 一类关键帧 TOD 思路启发,但并不是简单照搬。它最后形成了三层结构:
flowchart LR
P["TODProfile\n气候与全局参数"] --> K["TODKeyframe[]\n关键时刻"]
K --> C["TODController\n时间采样与插值"]
C --> S["TODSample\n只读类型化快照"]
S --> L["Sun / Moon Lights"]
S --> E["Environment\nAmbient / Fog / Glow / Exposure"]
S --> SKY["Sky Shader\n大气 / 日月 / 星空 / 银河"]
S --> CLD["Cloud Layers\n地平线 / 中层 / 高层"]
Profile 描述一种基础气候,Keyframe 描述某个时刻的美术状态,Controller 只负责时间和采样,Sample 则成为表现层之间的稳定协议。这样的拆分让天空可以被替换,环境可以被混合,云层也能独立演进。
二、v0.1:先让时间连续,而不是先堆参数
第一阶段解决的是最基础、也最容易被低估的问题:24 小时是一个环,而不是一条从 0 到 24 的直线。
假设最后一个关键帧在 23:30,下一个关键帧在 05:00,普通线性区间判断会直接失效。系统必须显式处理跨午夜段:
func _segment_duration(a_h: float, b_h: float) -> float:
if b_h >= a_h:
return b_h - a_h
return (24.0 - a_h) + b_h
func _segment_elapsed(a_h: float, hour: float) -> float:
if hour >= a_h:
return hour - a_h
return (24.0 - a_h) + hour
找到相邻关键帧后,插件对归一化进度使用 smoothstep:
var t := clampf(elapsed / duration, 0.0, 1.0)
t = t * t * (3.0 - 2.0 * t)
这里的重点不是公式有多复杂,而是所有视觉量都共享同一采样时刻:太阳高度、环境光、雾密度、天空色、云覆盖率、曝光和 Glow 不再各自维护一套时间判断。
太阳方向也没有被简化成“只插值灯光欧拉角”。关键帧只保存太阳高度,方位角由 24 小时连续轨道计算。这样太阳阴影会真实地从东向西移动,而不会在两个关键帧之间锁死方向。
三、v0.2:最难的产品化问题是“不要乱动用户场景”
一个项目内脚本常常会这样做:进入场景后搜索第一个 WorldEnvironment 和第一个 DirectionalLight3D,然后开始改参数。在自己的 Demo 里没有问题,放进别人项目就会成为隐患。
v0.2 的核心不是增加视觉特效,而是重新定义所有权:
auto_discover_targets默认关闭;world_environment_path、sun_light_path、moon_light_path显式绑定;manage_sky、manage_fog、manage_exposure等开关分别声明插件接管范围;manage_tonemap默认关闭,不擅自把用户项目切成 ACES;- 编辑器预览复制 Environment,并在关闭预览后恢复原资源和灯光状态;
TODEnvironmentMixer始终从捕获的基线计算,避免逐帧叠加造成数值漂移。
这一版还把动态字典 API 包装成只读的 TODSample。字典接口仍保留兼容,但新代码可以获得更清晰的类型提示:
func _on_tod_sample(sample: TODSample) -> void:
weather_ui.set_sun_energy(sample.sun_energy)
audio_bus.set_night_mix(sample.is_night)
从插件开发角度看,这一步非常关键。视觉效果决定用户是否感兴趣,所有权边界决定用户是否敢把它放进正式工程。
四、v0.3:从一套晴天参数,扩展为五种可编辑气候
v0.3 引入了五套基础气候:
| Profile | 主要美术方向 |
|---|---|
| Temperate | 均衡晴朗、暖色晨昏、适中云量 |
| Overcast | 冷灰低对比、封闭云层、柔和主光 |
| Arid | 暖色地平线、稀薄锐利云、干燥空气 |
| Coastal | 青蓝湿润环境、宽地平线雾、快速海风 |
| Cold | 冷白日光、深蓝夜空、强月光与密集星空 |
这些 Profile 共享相同的昼夜时段结构,但会重塑地平线、日月光晕、星空、云量、雾、曝光和环境光。它们不是“晴天、下雨、打雷”这类短期天气,而是天气系统下面的基础气候层。
Profile 切换也不能瞬间替换。transition_to_profile() 会捕获切换前的完整 Sample,再向目标 Profile 的当前时刻采样插值。因此,从寒冷夜空切到阴天气候时,灯光、雾和云不会各自跳变。
这套设计为下一阶段天气覆盖层留出了明确位置:
flowchart TB
BASE["基础气候 Profile\nTemperate / Coastal / Cold..."] --> MIX["当前 TOD Sample"]
WEATHER["临时天气覆盖层\nRain / Storm / Sandstorm..."] --> MIX
MIX --> OUT["最终天空、灯光、雾与云"]
五、第一次云层攻坚:一张天空贴图为什么必然露馅
最初的主云层在 Sky Shader 中使用经纬 UV 平铺。这个方案实现快,也天然跟随天空,但只要云图案有明确轮廓,两个问题几乎一定会出现:
- 经度
0/1边界产生拼接缝; - 图案频率一低,重复形状一眼可见;频率一高,云又会变成噪声。

早期失败方案:箭头位置能看到长直拼缝,云形也沿固定节奏重复。问题不在“贴图还不够大”,而在表现模型本身。
当时还出现过另一个误判:阴天观感像“雾填满了天空盒”,于是很容易继续加云密度或调雾色,结果云与雾互相吞噬。

阴天早期版本:天空、远景雾和主云层缺少层次边界,最后只剩一整块低对比颜色。
最终方案不是继续修补平铺贴图,而是把主云从天空 Shader 中移出,改成三组相机跟随的切线云卡:
- 地平线层:数量多、尺度大、移动慢;
- 中层:主要可读云形和视差;
- 高层:数量少、尺度薄、移动更快;
- 每张卡片在球形云穹的切平面上展开;
- 云穹跟随相机位置,因此没有远裁剪和玩家走出云层中心的问题。
flowchart LR
CAM["Camera"] --> DOME["Camera-following cloud dome"]
DOME --> H["Horizon cards\n84 instances"]
DOME --> M["Middle cards\n60 instances"]
DOME --> U["High cards\n40 instances"]
H --> ATLAS["2 x 4 atlas tiles"]
M --> ATLAS
U --> ATLAS

三层云卡接入后的阶段性画面。此时已经消除了整片天空平铺造成的经度接缝,但云色、夜间亮度和覆盖范围仍在继续调整。
三套源图分别对应地平线、中层和高层。源图不直接在运行时承担全部语义,而是由脚本离线打包为多通道图集。


六、第二次云层攻坚:让一张图同时承载形状、生命周期和边缘光
只有云卡还不够。如果每张卡只是静态透明图,它们会像贴在天空上的纸片。v0.3 为派生图集定义了四个通道:
| 通道 | 含义 |
|---|---|
| R | 原画内部明暗,用于保留云体绘制结构 |
| G | 边缘层,用于调制边缘光 |
| B | 从核心到外沿的生命周期场 |
| A | 云轮廓透明度 |

肉眼预览打包图会看到非自然颜色,因为 RGB 已经不再代表最终颜色,而是三类技术美术数据。
1. 用距离场做“小 → 大 → 小”
生成器按图集 tile 独立执行两遍 Chamfer Distance Transform,得到云内部到边界的近似距离。B 通道中,核心接近 1,边缘接近 0.08。
每张云卡从 INSTANCE_CUSTOM.z 取得独立相位:
float lifecycle_curve(float seed) {
float phase = fract(TIME * lifecycle_rate + seed);
float triangle = 1.0 - abs(phase * 2.0 - 1.0);
return triangle * triangle * (3.0 - 2.0 * triangle);
}
Fragment Shader 对 B 通道扫描阈值,Vertex Shader 同时改变面片尺寸。两者必须同步,否则只改透明轮廓会像“云被擦除”,只改顶点尺寸又会像整张贴纸缩放。
float growth_threshold = mix(0.84, 0.075, cloud_life);
float growth_mask = smoothstep(
growth_threshold - growth_softness,
growth_threshold + growth_softness,
packed_cloud.b
);
// vertex
VERTEX.xy *= mix(growth_min_scale, 1.0, cloud_life);
一次重要的视觉反馈是:第一版生命周期范围太大,大量云会在同一时段接近消失。最终默认最小尺寸收紧到 0.68,并保留至少 0.42 的生命周期 Alpha。技术美术参数的“数学范围完整”并不等于“视觉范围可用”。
2. 从深度差边缘光,改成适合透明云卡的轮廓差
常见边缘光做法会沿光照方向偏移屏幕深度并比较差异。但透明云卡没有可靠的场景深度,直接套用容易出现穿帮。
这里采用了同一思想的 UV/SDF 版本:
- 将太阳方向投影到云卡的 Right/Up 切线空间;
- 沿投影方向偏移采样 B 通道生命周期轮廓;
- 用当前轮廓减去偏移轮廓;
- 对差值执行 smoothstep 羽化;
- 再用 G 通道、太阳高度和气候参数调制。
vec2 tangent_light = vec2(
dot(sun_dir, normalize(cloud_right)),
-dot(sun_dir, normalize(cloud_up))
);
vec2 rim_uv = atlas_uv + rim_direction * rim_width_pixels * texel;
float rim_difference = max(current_shape - shifted_growth, 0.0);
float rim = smoothstep(0.0, rim_feather, rim_difference);
这让边缘光只出现在朝向太阳的一侧,而不是给整朵云套一圈均匀描边。
七、星空与银河:程序化资源不等于全程序化渲染
星空采用脚本生成的球面泊松采样点图。与逐像素 Hash 相比,泊松分布能控制星点之间的最小角距离,避免局部扎堆和规则网格感;生成结果仍是一张普通纹理,运行时采样成本和美术贴图一致。

银河则使用另一张多通道 Mask:R=亮核、G=整体带状范围、B=星尘细节,再用一张 RG 周期噪声做两组反向流动扰动。

银河是本轮最能体现“必须知道何时回退”的部分。我们曾尝试扩大程序化细节、改变球面映射和增加星点密度,结果画面出现大量拉伸短划线,离目标反而更远。那次实验最终整体撤回,只保留经过验证的双噪声分层框架。
即使回到更保守的方案,经纬 UV 的 atan 跳变仍然会在某些朝向暴露接缝:

接缝调试截图。此问题后来通过保证周期边界一致和修正经度采样处理解决。它也提醒我们:所谓“无缝噪声”必须同时满足源图周期、扰动周期和 Shader 采样边界三项条件。
这段经历留下了一个很实际的判断标准:
> 程序化生成的价值,是提供稳定、可再生、可参数化的素材协议;不是为了证明所有视觉细节都必须在一个 Shader 里凭空生成。
八、最后一公里:时间倍率为什么会暴露跨模块语义问题
当 Demo 加入时间倍率后,出现过一个典型的状态同步 Bug:HUD 开关默认显示为开启,但场景中的 is_time_flowing 实际保存为 false。因此初次加载不走时,必须“关一次再开一次”才生效。
修复方式不是在 _process() 里打补丁,而是明确单一真值来源:控制器保存真实状态,HUD 启动时用 set_pressed_no_signal() 同步显示。
随后又发现,天空 Shader 内的云 UV 已经乘了 time_scale,三层主云卡却仍按现实时间旋转。结果太阳以 8 倍速度移动,主云仍是 1 倍。
最终所有云移动共享同一外层倍率:
var speed_multiplier := 1.0
if profile.cloud_speed_follow_time_scale:
speed_multiplier = maxf(tod.time_scale, 0.0)
layer.rotation.y += base_cloud_speed * layer_speed * speed_multiplier * delta
插件仍保留 cloud_speed_follow_time_scale 开关。某些游戏希望昼夜快进时云也快进,另一些游戏希望云始终按现实秒移动,这应该是明确的产品选择,而不是偶然行为。
九、验证方式:自动测试负责确定性,截图负责审美问题
这个系统同时使用两类验证,它们不能互相替代。
自动回归适合验证
- Profile 是否至少有两个关键帧;
- 是否存在重复小时;
- 跨午夜采样是否正确;
- 1 倍与 8 倍时间推进是否成比例;
- 三层云卡的角速度是否同步乘倍率;
- 默认资源、Shader 参数和目标路径是否存在;
- 气候切换完成后信号与状态是否一致。
godot --headless --path . -s res://addons/stylized_tod_sky/tests/tod_plugin_test.gd
真实画面适合验证
- 云形是否重复;
- 接缝是否在特定视角出现;
- 阴天究竟是云厚,还是雾把天空洗平;
- 云生命周期是否让整片天空同时变空;
- 边缘光是否只出现在受光侧;
- 夜空星点密度和银河强度是否抢夺画面主体。
很多技术美术问题在日志中完全“正确”,在截图里却一眼错误。开发过程里,截图不是汇报材料,而是测试结果。
十、这次迭代真正沉淀下来的东西
回看 v0.1 到 v0.3,最有价值的并不是某一条 Shader 公式,而是几条可复用的工程经验。
1. 先定义资源所有权,再提供自动化
插件不应该因为“找得到一个方向光”就认为自己有权修改它。显式路径和 manage_* 开关看起来麻烦,却是通用插件最基本的安全边界。
2. 数据协议比参数数量重要
TODProfile -> TODKeyframe -> TODSample 让所有表现层共享一份时间语义。没有这个协议,五套气候只会变成五份越来越难同步的脚本。
3. 不要让同一种映射承担所有尺度
天空渐变、星图和远距离银河适合 Sky Shader;有明确形状、视差和生命周期的主云更适合独立云卡。混合架构不是妥协,而是针对不同视觉频率选择不同表示方式。
4. 多通道纹理是技术美术与运行时代码的契约
云图集的 RGBA 不再是颜色,而是身体明暗、边缘、生命周期和轮廓。只要协议稳定,源图可以替换,Shader 也可以继续升级。
5. 知道何时撤回
银河实验说明,方向错误时继续堆参数只会放大错误。保留失败截图、撤回未验证方案、重新缩小问题,比“坚持把复杂方案调到能看”更有效。
十一、下一步
v0.3 完成的是多气候基础和云层技术美术骨架。后续计划按以下顺序推进:
- 天气覆盖层与平滑过渡;
- 云层、日月和大气表现升级;
- 高度雾与体积效果;
- 时间轴 Dock、曲线编辑和反射探针工具。
其中最重要的架构决定已经完成:基础气候与临时天气分离,时间采样与表现层分离,天空背景与主云层分离。接下来的功能可以沿这些边界扩展,而不必再次推倒核心。
参考资料
- AHD2-TODSystem 技术思路整理
- 风格化天空与大气相关实现
- 基于通道/SDF 的云生长过程参考
- 基于偏移采样差值的边缘光思路
- Godot 4 官方文档:
SkyMaterial、ShaderMaterial、MultiMeshInstance3D、Environment
项目当前目录结构如下,迁移到独立仓库时只需要复制整个插件目录:
addons/stylized_tod_sky/
├── core/ # Controller、Profile、Keyframe、Sample、Mixer
├── data/ # 五套可编辑气候资源
├── demo/ # 自包含演示场景与测试模型
├── scenes/ # 控制器和云层预制体
├── shaders/ # 天空与云卡 Shader
├── textures/ # 原始素材、派生图集、星图和银河 Mask
├── tools/ # Profile 与视觉资产生成脚本
└── tests/ # Headless 回归测试
评论 (0)