从一片天空到可交付插件:Godot Stylized TOD Sky v0.3 攻坚实录

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

当前 Demo:时间轴、五套气候 Profile、自动时间流动与 0-100 倍时间缩放。测试建筑只是尺度和光照参照,不属于插件运行依赖。

一、它为什么会从“项目功能”变成“插件”

很多昼夜系统的第一版都很相似:一个脚本保存当前小时,按时间旋转方向光,再顺便改一下天空颜色。它足以完成一次演示,却很难进入下一款游戏。

真正开始复用时,问题会迅速出现:

  • 场景里已经有 WorldEnvironment 和主方向光,插件是否有权接管?
  • 日出、正午、黄昏和深夜之间,是连续采样还是硬编码分支?
  • 阴天、海岸和寒冷气候,是复制五份控制器,还是共享一套时间结构?
  • 编辑器预览修改了环境资源,退出预览后能否完整恢复?
  • 云是天空 Shader 中的一张平铺贴图,还是能独立表现视差、生命周期与受光方向的对象?
  • 时间倍率改变后,太阳、云层和 HUD 是否仍然处于同一时间语义?

这些问题共同指向一个结论:昼夜系统不应该只是一个“会转太阳的脚本”,而应该是一个有明确数据模型、资源所有权和表现层边界的插件。

Stylized TOD Sky 最初受 AHD2 一类关键帧 TOD 思路启发,但并不是简单照搬。它最后形成了三层结构:

mermaid
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,普通线性区间判断会直接失效。系统必须显式处理跨午夜段:

gdscript
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:

gdscript
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。字典接口仍保留兼容,但新代码可以获得更清晰的类型提示:

gdscript
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 的当前时刻采样插值。因此,从寒冷夜空切到阴天气候时,灯光、雾和云不会各自跳变。

这套设计为下一阶段天气覆盖层留出了明确位置:

mermaid
flowchart TB
    BASE["基础气候 Profile\nTemperate / Coastal / Cold..."] --> MIX["当前 TOD Sample"]
    WEATHER["临时天气覆盖层\nRain / Storm / Sandstorm..."] --> MIX
    MIX --> OUT["最终天空、灯光、雾与云"]

五、第一次云层攻坚:一张天空贴图为什么必然露馅

最初的主云层在 Sky Shader 中使用经纬 UV 平铺。这个方案实现快,也天然跟随天空,但只要云图案有明确轮廓,两个问题几乎一定会出现:

  1. 经度 0/1 边界产生拼接缝;
  2. 图案频率一低,重复形状一眼可见;频率一高,云又会变成噪声。

早期失败方案:箭头位置能看到长直拼缝,云形也沿固定节奏重复。问题不在“贴图还不够大”,而在表现模型本身。

当时还出现过另一个误判:阴天观感像“雾填满了天空盒”,于是很容易继续加云密度或调雾色,结果云与雾互相吞噬。

阴天早期版本:天空、远景雾和主云层缺少层次边界,最后只剩一整块低对比颜色。

最终方案不是继续修补平铺贴图,而是把主云从天空 Shader 中移出,改成三组相机跟随的切线云卡:

  • 地平线层:数量多、尺度大、移动慢;
  • 中层:主要可读云形和视差;
  • 高层:数量少、尺度薄、移动更快;
  • 每张卡片在球形云穹的切平面上展开;
  • 云穹跟随相机位置,因此没有远裁剪和玩家走出云层中心的问题。
mermaid
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

![地平线云源图](./b

三层云卡接入后的阶段性画面。此时已经消除了整片天空平铺造成的经度接缝,但云色、夜间亮度和覆盖范围仍在继续调整。

三套源图分别对应地平线、中层和高层。源图不直接在运行时承担全部语义,而是由脚本离线打包为多通道图集。

六、第二次云层攻坚:让一张图同时承载形状、生命周期和边缘光

只有云卡还不够。如果每张卡只是静态透明图,它们会像贴在天空上的纸片。v0.3 为派生图集定义了四个通道:

通道 含义
R 原画内部明暗,用于保留云体绘制结构
G 边缘层,用于调制边缘光
B 从核心到外沿的生命周期场
A 云轮廓透明度

肉眼预览打包图会看到非自然颜色,因为 RGB 已经不再代表最终颜色,而是三类技术美术数据。

1. 用距离场做“小 → 大 → 小”

生成器按图集 tile 独立执行两遍 Chamfer Distance Transform,得到云内部到边界的近似距离。B 通道中,核心接近 1,边缘接近 0.08。

每张云卡从 INSTANCE_CUSTOM.z 取得独立相位:

glsl
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 同时改变面片尺寸。两者必须同步,否则只改透明轮廓会像“云被擦除”,只改顶点尺寸又会像整张贴纸缩放。

glsl
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 版本:

  1. 将太阳方向投影到云卡的 Right/Up 切线空间;
  2. 沿投影方向偏移采样 B 通道生命周期轮廓;
  3. 用当前轮廓减去偏移轮廓;
  4. 对差值执行 smoothstep 羽化;
  5. 再用 G 通道、太阳高度和气候参数调制。
glsl
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 倍。

最终所有云移动共享同一外层倍率:

gdscript
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 参数和目标路径是否存在;
  • 气候切换完成后信号与状态是否一致。
powershell
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 完成的是多气候基础和云层技术美术骨架。后续计划按以下顺序推进:

  1. 天气覆盖层与平滑过渡;
  2. 云层、日月和大气表现升级;
  3. 高度雾与体积效果;
  4. 时间轴 Dock、曲线编辑和反射探针工具。

其中最重要的架构决定已经完成:基础气候与临时天气分离,时间采样与表现层分离,天空背景与主云层分离。接下来的功能可以沿这些边界扩展,而不必再次推倒核心。

参考资料


项目当前目录结构如下,迁移到独立仓库时只需要复制整个插件目录:

text
addons/stylized_tod_sky/
├── core/       # Controller、Profile、Keyframe、Sample、Mixer
├── data/       # 五套可编辑气候资源
├── demo/       # 自包含演示场景与测试模型
├── scenes/     # 控制器和云层预制体
├── shaders/    # 天空与云卡 Shader
├── textures/   # 原始素材、派生图集、星图和银河 Mask
├── tools/      # Profile 与视觉资产生成脚本
└── tests/      # Headless 回归测试