從零做出空戰遊戲的體積雲:Godot 4 實作與效能調校
這陣子在把一款台灣的 HTML5 射擊遊戲《台海雄鷹》改寫成 Godot 4 的 3D 空戰遊戲。飛機會在 50 到 2000 公尺高度、以時速數百到上千公里飛行,所以「雲」不只是背景:玩家會從雲底下穿過、鑽進雲裡、在雲上面纏鬥。這篇記錄我怎麼從零做出一套體積雲與天氣系統,以及最後怎麼把它調到三檔畫質、並修掉一個很難察覺的畫面瑕疵。
📦 範例程式:文中的體積雲我抽成了一個可以直接執行的 Godot 4 範例,打開就能在雲裡飛、切換天氣和畫質,clouds/ 資料夾也可以直接複製到自己的專案使用(MIT 授權):https://github.com/smdexw/godot-volumetric-clouds

從 4200 公尺俯看的晴天積雲(高畫質)
一、先研究「要做什麼」,再自己寫
我手邊有一套 Unity 的天氣商業套件。它功能很完整:體積雲、降雨、閃電、霧、天空、日夜、風、極光……但它是為一般遊戲設計的,也不能直接搬到 Godot。所以我的做法是:只參考它「有哪些功能、整體架構怎麼分工」,程式碼一行都不用,依照空戰遊戲的需求重新設計。
整理下來,對空戰最有價值的依序是:天氣設定與轉換系統、體積雲、霧與空氣透視、閃電、海面雲影、降雨、日夜循環。極光、積雪、地面濕氣這些在海上用不到,直接不做。
另外有幾個「一般遊戲沒問題、但空戰會出事」的地方,從一開始就要避開:
- 高速穿雲:飛機一秒前進一百多公尺,雲旁邊的畫面變化非常大。
- 降雨:跟著鏡頭的雨粒子是照步行速度設計的,時速一千公里時全部被甩在後面。
- 雲在飛行高度裡:雲層剛好落在戰鬥的高度範圍,「飛進雲裡」是常態,不是例外。
二、天氣系統:一種天氣是一組數字
我把一種天氣定義成一個 WeatherProfile 資源:雲量、雲底和雲頂高度、雲的種類(層雲到積雲)、密度、暗度、雨量、閃電頻率、霧、太陽被遮掉多少、高空卷雲……目前有晴、少雲、多雲、陣雨、陰、暴風雨、薄霧幾種預設。
換天氣時不是全部一起漸變,而是錯開:要下雨時,雲先變厚(用掉 70% 的時間),雨在 40% 時才開始下;放晴時雨很快停,雲慢慢散。這個小細節讓天氣轉換看起來很自然。

左上晴天積雲、右上陰天、左下暴風雨、右下從暴風雨雲底往上看
三、體積雲:光線步進
雲是用光線步進(ray marching)畫的:每個像素沿著視線,在雲層高度範圍內一步一步取樣雲的密度,同時估算陽光照到那一點還剩多少。
密度怎麼來
- 天氣圖:一張可以無縫重複的 2D 雜訊,涵蓋約 36 公里,決定哪裡有雲。雲塊越往上越縮,所以邊緣是圓頂而不是垂直的牆。
- 高度輪廓:層雲是低處的一層薄片,積雲從平平的雲底長到圓圓的雲頂,用一個參數在兩者之間混合。
- 3D 雜訊:低頻的 Perlin 雜訊當雲的主體,被 Worley(細胞)雜訊侵蝕出一團一團的感覺,再用高頻雜訊把邊緣磨成絲狀(雲底)和花椰菜狀(雲頂)。雜訊都用 Godot 內建的
NoiseTexture3D在開場產生。
光照
- 朝太陽方向再取 4 到 5 個點,用 Beer-Lambert 消光算陽光被擋掉多少。
- 雙瓣 Henyey-Greenstein 相位函數:順光時雲的邊緣會發亮,背光也有一點反光。
- 多重散射近似:疊加 3 層「越來越弱、越來越穿透」的光,讓厚雲的雲頂更亮、更有體積感。
- 環境光:上方取天空色、下方取海面色,雲底自然偏灰。
- 不透明度用正確的
1 − exp(−密度 × 步長),這樣步長改變時雲的外觀不會跟著變。
步進迴圈的核心長這樣(GLSL,變數名稱有簡化、省略了宣告):
// clouds_march.glsl:沿視線步進,正確的 Beer-Lambert 積分
for (int i = 0; i < n; i++) {
if (t >= t1 || trans < min_trans) break; // 夠不透明就提早停
float ds = clamp(max(base_step, t * step_growth), 10.0, 700.0);
vec3 pos = ro + rd * (t + ds * 0.5);
float dens = cloud_density(pos, t < near_distance);
if (dens > 0.001) {
float sigma = dens * density; // 消光係數(每公尺)
float od = light_depth(pos) * density * light_absorption;
// 多重散射近似:3 層越來越弱、越來越穿透的光
float lum = 0.0, a = 1.0, b = 1.0;
for (int k = 0; k < 3; k++) {
lum += a * exp(-od * b) * mix(phase, 1.0, 1.0 - b);
a *= 0.5; b *= 0.35;
}
vec3 s = sun_light * lum + ambient;
float step_trans = exp(-sigma * ds); // 這一步透過多少
col += trans * s * (1.0 - step_trans); // 和步長無關的寫法
trans *= step_trans;
}
t += ds;
}
飛進雲裡也要正確
步長隨距離加長:近處很細(不會看到一片片切片)、遠處很長(省效能)。每個像素都會讀取場景的深度,碰到飛機或海面就停,所以飛機在雲前面時邊緣是清楚的。

左:從雲層上方俯看;右:600 公尺低空,海面上看得到一塊塊雲影
四、從全螢幕面改成 Compositor 運算著色器
第一版是一個貼在鏡頭前的全螢幕面,用一般的 spatial shader 做光線步進。畫面沒問題,但每個像素都要走完整趟。最吃重的情況(陰天、從雲層上方往下看)要花好幾毫秒。
第二版改成 Godot 4 的 CompositorEffect,用兩個運算著色器:
- 半解析度步進:只算四分之一的像素,同時記下「雲從多遠開始」。
- 全解析度合成:把雲平滑放大,混入上一幀降低雜訊;如果某個像素的實體物件(例如飛機)比雲開始的距離還近,就把那個像素的雲挖掉,所以飛機邊緣仍是全解析度、不會糊。
Compositor 的骨架(GDScript,省略了建立 shader 與 uniform 的部分):
# clouds_effect.gd:在半透明物件之前跑兩個運算著色器
class_name CloudsEffect
extends CompositorEffect
func _init() -> void:
effect_callback_type = EFFECT_CALLBACK_TYPE_PRE_TRANSPARENT
access_resolved_depth = true
_rd = RenderingServer.get_rendering_device()
RenderingServer.call_on_render_thread(_create) # 在渲染執行緒建立 shader 與 pipeline
func _render_callback(_type: int, render_data: RenderData) -> void:
var buffers := render_data.get_render_scene_buffers() as RenderSceneBuffersRD
var full := buffers.get_internal_size()
var half := full / divisor # 2=半解析度、3、4
var depth := buffers.get_depth_layer(0) # 場景深度,用來讓飛機擋住雲
# ……更新參數緩衝區、建立 uniform set(略)……
var list := _rd.compute_list_begin()
_rd.compute_list_bind_compute_pipeline(list, _march_pipeline)
_rd.compute_list_bind_uniform_set(list, march_set, 0)
_rd.compute_list_dispatch(list, (half.x + 7) / 8, (half.y + 7) / 8, 1)
_rd.compute_list_end()
# 第二步:放大、混入上一幀、依深度挖掉飛機前面的雲,結果寫進 _final
list = _rd.compute_list_begin()
_rd.compute_list_bind_compute_pipeline(list, _comp_pipeline)
_rd.compute_list_bind_uniform_set(list, comp_set, 0)
_rd.compute_list_dispatch(list, (full.x + 7) / 8, (full.y + 7) / 8, 1)
_rd.compute_list_end()
這裡有個坑:機翼的白色氣流線、飛彈的煙、雨絲都是半透明的,它們不寫入深度。如果雲在它們之後才疊上去,就會把它們蓋掉。我試過在「半透明物件之前」直接寫進畫面,結果會被 Godot 後面的步驟蓋掉。最後的解法是:運算著色器只負責算出雲的畫面,存成一張貼圖(Texture2DRD),再由一個「半透明物件中最先畫」的全螢幕面貼上去,這些半透明效果就能正確畫在雲上面。
# 運算著色器的結果包成一般貼圖,給全螢幕面使用
var display := Texture2DRD.new()
# 建立或改變大小時(在渲染執行緒),交給主執行緒設定
display.set_deferred("texture_rd_rid", _final)
# clouds_show.gdshader:半透明物件裡最先畫,所以氣流線、煙、雨會畫在雲上面
shader_type spatial;
render_mode unshaded, depth_draw_never, depth_test_disabled, blend_premul_alpha;
uniform sampler2D clouds : filter_nearest;
void vertex() { POSITION = vec4(VERTEX.xy, 1.0, 1.0); } // 蓋滿整個畫面
void fragment() {
vec4 c = texture(clouds, SCREEN_UV);
ALBEDO = c.rgb;
ALPHA = c.a;
}
# 材質設定 render_priority = -100,排在其他半透明物件之前
五、雲不只是畫面:雲影、遮擋、雲內效果
- 雲影:海面的 shader 用同一張天氣圖和雜訊,沿著陽光方向查雲層,算出一塊塊雲影。
- 玩法:我在 CPU 端用和 shader 相同的天氣圖與雜訊寫了一個查詢函式。雲夠厚時,玩家鎖不到雲後的目標(HUD 標「雲層遮蔽」)、追蹤彈有機率失去目標、敵人看不到雲裡的你,雲變成可以躲的地方。
- 雲內:畫面起霧、水氣條紋依飛行速度從中央往外流(出雲後慢慢乾)、機身被亂流搖晃。
六、高速雨絲、雨幕和雷聲
雨改成「一盒跟著鏡頭、稍微偏前方的雨滴」,每滴依「下落速度 + 風 − 鏡頭速度」的相對速度在盒子裡循環移動,畫成一個曝光時間內劃過的線段。高速時雨會從前方成線地衝過來,慢速時就直直落下。雨只在頭上有雨雲、而且在雲頂以下時才下。
遠處的雨幕畫在同一個步進裡:雲底到海面之間,只在天氣圖最濃的雲核下方畫灰色、帶垂直條紋的雨柱,讓暴風雨從遠處就看得到。雷聲則依閃電距離延遲,每 343 公尺延遲 1 秒;近處是爆裂聲,遠處只剩低沉的隆隆聲。

陣雨天氣,雲底下的海平線附近有一條條雨柱
七、日夜循環與卷雲
太陽位置用北緯 23.5°(台灣)的簡化天文公式計算。每一關開場還是原本設計的天色,之後的顏色是「原本的顏色 × 相對開場時的光線變化」,所以不會破壞關卡的設計。入夜時太陽光淡出、改成月光,星星出現,雲被月光照成灰白色。高空另外畫了一層卷雲,會被夕陽染成粉橘色。

加速的日夜循環:13:25 → 16:09 → 17:55 → 夕陽 → 19:11 入夜 → 20:45
八、效能調校:先做工具,再調參數
功能做完後,我想知道「到底花多少時間、怎麼省」。一開始我用 fps 估算,得到的結果是只要 0.2 毫秒,後來證明是錯的。所以我做了一個自動化的測試:
- 在 Compositor 的兩個運算步驟前後打 GPU 時間戳記,直接量雲花多少時間。
- 固定鏡頭依序看 5 種天氣 × 5 種視角(雲下平視、雲下仰視、雲中、雲上俯視、雲上遠望),各取 1.5 秒平均。
- 每組參數存一批截圖,和「全解析度、128 步」的最高品質參考圖比較。
# 量 GPU 時間:在兩個運算步驟前後打時間戳記(單位是奈秒)
_rd.capture_timestamp("clouds_begin")
# ……步進……
_rd.capture_timestamp("clouds_march")
# ……合成……
_rd.capture_timestamp("clouds_done")
# 下一幀讀回上一幀的結果
for i in _rd.get_captured_timestamps_count():
t[_rd.get_captured_timestamp_name(i)] = _rd.get_captured_timestamp_gpu_time(i)
march_ms = (t.clouds_march - t.clouds_begin) / 1000000.0
第一個教訓:測效能時要關掉垂直同步。開著時 GPU 負載低會自動降頻,量到的時間偏高而且忽高忽低,甚至出現「中畫質比高畫質慢」這種不合理的結果。關掉之後兩次測量的結果就一致了。
第二個教訓:數值比對看不出解析度造成的模糊。雲會隨風飄、海浪會動,截圖之間本來就有差異,解析度造成的柔化被這些差異蓋過了。最後還是把同一塊雲裁切放大,用眼睛比。
單項參數的結論(以原本設定為基準):
| 調整 | 耗時變化 | 畫質 |
|---|---|---|
| 運算解析度 1/2 → 1/3 | −34% | 幾乎看不出差別,最划算 |
| 視線步數 72 → 48 | −19% | 划算 |
| 光照步數 5 → 3 | −8% | 變差,不划算 |
| 近處細節範圍 6 → 3 公里 | −6% | 明顯變差,不採用 |
| 遠處步長放大、最遠距離縮短 | 幾乎沒省 | 不採用 |
九、調校時發現的瑕疵:「重疊加上位移」
測到低畫質時,雲上出現一層層橫向條紋,看起來像好幾片雲疊在一起又錯開。其實一般畫質在某些角度也有,只是比較淡。

修正前:左上中畫質,其餘三格是低畫質候選,雲上看得到一層層的橫向條紋
原因有三個,修正方法也各不相同:
- 層疊切片(主因):光線步進每一步的起點會隨機錯開,把切片打散成細雜訊。但我把錯開的幅度上限寫死成 80 公尺,遠處一步卻有數百公尺,切片沒被打散。改成錯開幅度等於當下第一步的長度就好了。
- 直向細紋:每幀的錯開圖案只是平移,前後幀平均不掉。改成固定的像素圖案、每幀加上黃金比例(0.618…)遞增,幾幀內每個像素都試過均勻分布的位置。
- 移動殘影:混入上一幀時沒有依鏡頭移動對齊位置。改成依「鏡頭這一幀移動的距離 × 焦距 ÷ 雲的距離」估算畫面位移,位移超過約一個像素就少混一點。
前兩個問題都在步進起點那幾行:
// 修正前:錯開幅度最多 80 公尺,遠處一步有數百公尺 → 看到一層層切片
float jitter = fract(52.9829189 * fract(dot(px + time * vec2(13.7, 7.1) * 60.0,
vec2(0.06711056, 0.00583715))));
float t = t0 + jitter * clamp(base_step, 10.0, 80.0);
// 修正後:錯開一整個「第一步」的長度;每幀加黃金比例,幾幀內均勻試過所有位置
float frame = floor(time * 60.0);
float jitter = fract(52.9829189 * fract(dot(px, vec2(0.06711056, 0.00583715)))
+ frame * 0.61803398875);
float t = t0 + jitter * clamp(max(base_step, t0 * step_growth), 10.0, 700.0);
殘影的處理在合成的著色器裡:
// clouds_composite.glsl:上一幀沒有對齊位置,所以依畫面位移決定能混多少
float front = texture(front_tex, uv).r; // 這個像素的雲從多遠開始
float shift = cam_moved_m * focal_px / max(front, 1.0); // 這一幀雲在畫面上移了幾個像素
old = clamp(old, min(lo, cloud), max(hi, cloud)); // 先夾在鄰近像素範圍內
cloud = mix(cloud, old, keep * clamp(1.5 - shift, 0.0, 1.0));

修正後:由左到右是最高品質參考、高、中、低(上排俯看、下排仰看),只差在邊緣的柔和程度
十、最後的三檔畫質
| 低 | 中 | 高 | |
|---|---|---|---|
| 雲的運算解析度 | 1/4 | 1/3 | 1/2 |
| 視線步數/光照步數 | 48/4 | 48/5 | 72/5 |
| 反鋸齒 | FXAA | FXAA | MSAA 2× |
| 陰影距離 | 600 m | 1000 m | 1500 m |
| 雲平均耗時 | 0.23 ms | 0.32 ms | 0.72 ms |
| 整個畫面 GPU 時間 | 1.0 ms | 1.4 ms | 1.9 ms |
以上是在 RTX 3060、1600×900 下量的。高畫質整個畫面也不到 2 毫秒,離 60 fps 的 16.7 毫秒還很遠。預設用「中」,是為了照顧入門顯卡;內建顯示晶片建議用「低」。最差的情況都是「陰天或陣雨時在雲裡面」,因為雲層薄又有空隙,光線一路走到最遠才停。這也是之後可以再優化的方向。
參考資料
- Andrew Schneider(Guerrilla Games),The Real-Time Volumetric Cloudscapes of Horizon Zero Dawn,SIGGRAPH 2015 Advances in Real-Time Rendering。天氣圖、Perlin-Worley 雜訊、雲種高度輪廓、Beer 與 powder 光照的經典做法。
- Andrew Schneider(Guerrilla Games),Nubis: Authoring Real-Time Volumetric Cloudscapes with the Decima Engine,SIGGRAPH 2017。上一篇的延伸,可以飛進雲裡的做法。
- Sébastien Hillaire(EA Frostbite),Physically Based Sky, Atmosphere and Cloud Rendering in Frostbite(PDF),SIGGRAPH 2016 Physically Based Shading course。能量守恆的步進積分、雙瓣相位函數、多重散射近似。
- Magnus Wrenninge 等人,Oz: The Great and Volumetric,SIGGRAPH 2013 Talks。多層八度的多重散射近似來源。
- Jorge Jimenez,Next Generation Post Processing in Call of Duty: Advanced Warfare,SIGGRAPH 2014。文中用來錯開步進起點的 interleaved gradient noise。
- Martin Roberts,The Unreasonable Effectiveness of Quasirandom Sequences。每幀加黃金比例的低差異序列。
- Godot 官方文件:The Compositor、Texture2DRD。
心得
- 先想清楚「這個遊戲需要什麼」,比照搬一套完整的套件更重要。很多設計在步行速度下沒問題,到了空戰就會出事。
- 效能一定要用 GPU 時間戳記量,而且要關掉垂直同步。用 fps 估算可能差好幾倍。
- 畫面瑕疵往往不是演算法錯,而是某個寫死的數字(這次是 80 公尺)。做自動化的測試工具,固定視角反覆比對,才找得到。
使用工具:Godot 4.7(GDScript、Compositor、GLSL 運算著色器)、Python 與 FFmpeg 做截圖比對。