從零做出空戰遊戲的體積雲:Godot 4 實作與效能調校

這陣子在把一款台灣的 HTML5 射擊遊戲《台海雄鷹》改寫成 Godot 4 的 3D 空戰遊戲。飛機會在 50 到 2000 公尺高度、以時速數百到上千公里飛行,所以「雲」不只是背景:玩家會從雲底下穿過、鑽進雲裡、在雲上面纏鬥。這篇記錄我怎麼從零做出一套體積雲與天氣系統,以及最後怎麼把它調到三檔畫質、並修掉一個很難察覺的畫面瑕疵。

📦 範例程式:文中的體積雲我抽成了一個可以直接執行的 Godot 4 範例,打開就能在雲裡飛、切換天氣和畫質,clouds/ 資料夾也可以直接複製到自己的專案使用(MIT 授權):https://github.com/smdexw/godot-volumetric-clouds

從 4200 公尺俯看的晴天積雲(高畫質)
從 4200 公尺俯看的晴天積雲(高畫質)

一、先研究「要做什麼」,再自己寫

我手邊有一套 Unity 的天氣商業套件。它功能很完整:體積雲、降雨、閃電、霧、天空、日夜、風、極光……但它是為一般遊戲設計的,也不能直接搬到 Godot。所以我的做法是:只參考它「有哪些功能、整體架構怎麼分工」,程式碼一行都不用,依照空戰遊戲的需求重新設計。

整理下來,對空戰最有價值的依序是:天氣設定與轉換系統、體積雲、霧與空氣透視、閃電、海面雲影、降雨、日夜循環。極光、積雪、地面濕氣這些在海上用不到,直接不做。

另外有幾個「一般遊戲沒問題、但空戰會出事」的地方,從一開始就要避開:

  • 高速穿雲:飛機一秒前進一百多公尺,雲旁邊的畫面變化非常大。
  • 降雨:跟著鏡頭的雨粒子是照步行速度設計的,時速一千公里時全部被甩在後面。
  • 雲在飛行高度裡:雲層剛好落在戰鬥的高度範圍,「飛進雲裡」是常態,不是例外。

二、天氣系統:一種天氣是一組數字

我把一種天氣定義成一個 WeatherProfile 資源:雲量、雲底和雲頂高度、雲的種類(層雲到積雲)、密度、暗度、雨量、閃電頻率、霧、太陽被遮掉多少、高空卷雲……目前有晴、少雲、多雲、陣雨、陰、暴風雨、薄霧幾種預設。

換天氣時不是全部一起漸變,而是錯開:要下雨時,雲先變厚(用掉 70% 的時間),雨在 40% 時才開始下;放晴時雨很快停,雲慢慢散。這個小細節讓天氣轉換看起來很自然。

左上晴天積雲、右上陰天、左下暴風雨、右下從暴風雨雲底往上看
左上晴天積雲、右上陰天、左下暴風雨、右下從暴風雨雲底往上看

三、體積雲:光線步進

雲是用光線步進(ray marching)畫的:每個像素沿著視線,在雲層高度範圍內一步一步取樣雲的密度,同時估算陽光照到那一點還剩多少。

密度怎麼來

  1. 天氣圖:一張可以無縫重複的 2D 雜訊,涵蓋約 36 公里,決定哪裡有雲。雲塊越往上越縮,所以邊緣是圓頂而不是垂直的牆。
  2. 高度輪廓:層雲是低處的一層薄片,積雲從平平的雲底長到圓圓的雲頂,用一個參數在兩者之間混合。
  3. 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 公尺低空,海面上看得到一塊塊雲影
左:從雲層上方俯看;右:600 公尺低空,海面上看得到一塊塊雲影

四、從全螢幕面改成 Compositor 運算著色器

第一版是一個貼在鏡頭前的全螢幕面,用一般的 spatial shader 做光線步進。畫面沒問題,但每個像素都要走完整趟。最吃重的情況(陰天、從雲層上方往下看)要花好幾毫秒。

第二版改成 Godot 4 的 CompositorEffect,用兩個運算著色器:

  1. 半解析度步進:只算四分之一的像素,同時記下「雲從多遠開始」。
  2. 全解析度合成:把雲平滑放大,混入上一幀降低雜訊;如果某個像素的實體物件(例如飛機)比雲開始的距離還近,就把那個像素的雲挖掉,所以飛機邊緣仍是全解析度、不會糊。

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
加速的日夜循環: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%明顯變差,不採用
遠處步長放大、最遠距離縮短幾乎沒省不採用

九、調校時發現的瑕疵:「重疊加上位移」

測到低畫質時,雲上出現一層層橫向條紋,看起來像好幾片雲疊在一起又錯開。其實一般畫質在某些角度也有,只是比較淡。

修正前:左上中畫質,其餘三格是低畫質候選,雲上看得到一層層的橫向條紋
修正前:左上中畫質,其餘三格是低畫質候選,雲上看得到一層層的橫向條紋

原因有三個,修正方法也各不相同:

  1. 層疊切片(主因):光線步進每一步的起點會隨機錯開,把切片打散成細雜訊。但我把錯開的幅度上限寫死成 80 公尺,遠處一步卻有數百公尺,切片沒被打散。改成錯開幅度等於當下第一步的長度就好了。
  2. 直向細紋:每幀的錯開圖案只是平移,前後幀平均不掉。改成固定的像素圖案、每幀加上黃金比例(0.618…)遞增,幾幀內每個像素都試過均勻分布的位置。
  3. 移動殘影:混入上一幀時沒有依鏡頭移動對齊位置。改成依「鏡頭這一幀移動的距離 × 焦距 ÷ 雲的距離」估算畫面位移,位移超過約一個像素就少混一點。

前兩個問題都在步進起點那幾行:

// 修正前:錯開幅度最多 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/41/31/2
視線步數/光照步數48/448/572/5
反鋸齒FXAAFXAAMSAA 2×
陰影距離600 m1000 m1500 m
雲平均耗時0.23 ms0.32 ms0.72 ms
整個畫面 GPU 時間1.0 ms1.4 ms1.9 ms

以上是在 RTX 3060、1600×900 下量的。高畫質整個畫面也不到 2 毫秒,離 60 fps 的 16.7 毫秒還很遠。預設用「中」,是為了照顧入門顯卡;內建顯示晶片建議用「低」。最差的情況都是「陰天或陣雨時在雲裡面」,因為雲層薄又有空隙,光線一路走到最遠才停。這也是之後可以再優化的方向。

參考資料

心得

  • 先想清楚「這個遊戲需要什麼」,比照搬一套完整的套件更重要。很多設計在步行速度下沒問題,到了空戰就會出事。
  • 效能一定要用 GPU 時間戳記量,而且要關掉垂直同步。用 fps 估算可能差好幾倍。
  • 畫面瑕疵往往不是演算法錯,而是某個寫死的數字(這次是 80 公尺)。做自動化的測試工具,固定視角反覆比對,才找得到。

使用工具:Godot 4.7(GDScript、Compositor、GLSL 運算著色器)、Python 與 FFmpeg 做截圖比對。

完整範例程式碼:https://github.com/smdexw/godot-volumetric-clouds

此網誌的熱門文章

KV-7

O-I(試製120t超重戰車「オイ」)