Godotで画面全体を歪ませる演出を入れようとすると、けっこう面倒ですよね。
典型的なのは:

  • シーンごとに CanvasLayerColorRect を追加して、毎回シェーダーをコピペ
  • プレイヤーや敵のノードツリーに「エフェクト用ノード」が増えて、階層がどんどん深くなる
  • 「このシーンには入れたけど、あっちのシーンには入れ忘れた…」という事故

継承ベースで「エフェクト付きのベースシーン」を作ったとしても、今度は「エフェクト要らないシーン」や「別のエフェクトを重ねたいシーン」で柔軟性がなくなってしまいます。
そこで今回は、どのシーンにもポン付けできる「画面歪みコンポーネント」として、LensDistortion (魚眼レンズ) を用意してみました。

このコンポーネントをシーンルートにアタッチするだけで:

  • 画面中心を魚眼レンズ風に歪ませる
  • ワープ/衝撃波/必殺技発動時の「ぐにゃっ」とした演出
  • パラメータをコードからいじって、時間経過で歪みをアニメーション

といった表現が、ノード階層を汚さずに実現できます。

【Godot 4】ワープ&衝撃波の「ぐにゃっ」をコンポーネント化!「LensDistortion」コンポーネント

以下は、Godot 4 用のコンポーネント指向 LensDistortion のフルコードです。
シーンのルート(2Dゲームなら Node2D / CanvasLayer / Control など)にこのノードを 1 個足すだけで、画面全体に歪みシェーダーを適用できます。

GDScript フルコード


extends Node
class_name LensDistortion
##
## 画面中心を魚眼レンズ風に歪ませるコンポーネント。
## - シーンのルートに 1 個置くだけで、画面全体に適用されます。
## - ワープ、衝撃波、必殺技演出などに使えます。
##
## 想定用途:
##   - 2Dゲーム(CanvasItem 系)でのポストエフェクト
##   - 画面全体の一時的な歪み演出
##

@export_category("Lens Distortion Settings")

## 歪みの強さ。
## 0.0 で無効、正の値で魚眼レンズ風に中央を膨らませる。
## 1.0 以上にするとかなり極端な歪みになります。
@export_range(0.0, 2.0, 0.01)
var intensity: float = 0.6:
	set(value):
		intensity = value
		_update_shader_params()

## 歪みの半径(画面のどこまで影響するか)。
## 0.0 〜 1.5 くらいを想定。1.0 で画面全体をカバーするくらい。
@export_range(0.1, 2.0, 0.01)
var radius: float = 1.0:
	set(value):
		radius = value
		_update_shader_params()

## 歪みの中心位置。画面中央を (0.5, 0.5) とする UV 座標系。
## 中心をずらして「左側だけ歪ませる」なども可能です。
@export var center: Vector2 = Vector2(0.5, 0.5):
	set(value):
		center = value
		_update_shader_params()

## 歪みのフェード(0.0 = 完全透明 / 1.0 = フル適用)
## アニメーション用に用意しておくと便利です。
@export_range(0.0, 1.0, 0.01)
var mix_amount: float = 1.0:
	set(value):
		mix_amount = value
		_update_shader_params()

## 衝撃波っぽい「波紋」のオンオフ。
## ON にすると、中心から外側に向かうリング状のゆらぎが追加されます。
@export var enable_ripple: bool = false:
	set(value):
		enable_ripple = value
		_update_shader_params()

## 波紋の強さ。0.0 で無効。
@export_range(0.0, 0.5, 0.01)
var ripple_amplitude: float = 0.1:
	set(value):
		ripple_amplitude = value
		_update_shader_params()

## 波紋の周波数(リングの細かさ)。
@export_range(1.0, 30.0, 0.1)
var ripple_frequency: float = 12.0:
	set(value):
		ripple_frequency = value
		_update_shader_params()

## 波紋のスピード。正の値で外向き、負の値で内向きに流れます。
@export_range(-10.0, 10.0, 0.1)
var ripple_speed: float = 4.0:
	set(value):
		ripple_speed = value
		_update_shader_params()

@export_category("Runtime Control")

## 自動的に時間経過をシェーダーに渡すかどうか。
## 衝撃波アニメーションを使う場合は true 推奨。
@export var auto_animate_time: bool = true

## 複数の LensDistortion がいても、時間を共有したい場合に使えるオフセット。
@export var time_offset: float = 0.0

# 内部用: 実際に描画を行う ColorRect
var _rect: ColorRect
# 内部用: シェーダーマテリアル
var _shader_material: ShaderMaterial

# 内部用: 経過時間
var _time: float = 0.0


func _ready() -> void:
	# 画面全体を覆う ColorRect を生成して、Viewport に対してフルスクリーンで描画します。
	_rect = ColorRect.new()
	_rect.name = "LensDistortionRect"
	_rect.color = Color(1, 1, 1, 1)  # 色は使わず、テクスチャをそのまま出すだけ

	# Viewport サイズに合わせてフルスクリーンに広げる
	_rect.anchor_left = 0.0
	_rect.anchor_top = 0.0
	_rect.anchor_right = 1.0
	_rect.anchor_bottom = 1.0
	_rect.offset_left = 0.0
	_rect.offset_top = 0.0
	_rect.offset_right = 0.0
	_rect.offset_bottom = 0.0

	# このノードが Control でない場合でも動くように、CanvasLayer を中継に使う
	var canvas_layer := CanvasLayer.new()
	canvas_layer.name = "LensDistortionLayer"
	add_child(canvas_layer)
	canvas_layer.add_child(_rect)

	# ビューポートのテクスチャを取得し、それを元にポストエフェクトをかける
	var viewport_texture := get_viewport().get_texture()

	# シェーダーを生成
	var shader := Shader.new()
	shader.code = _get_shader_code()
	_shader_material = ShaderMaterial.new()
	_shader_material.shader = shader
	_shader_material.set_shader_parameter("screen_texture", viewport_texture)

	_rect.material = _shader_material

	# 初期パラメータを反映
	_update_shader_params()

	# ビューポートサイズ変更に追従(ウィンドウリサイズなど)
	if get_viewport():
		get_viewport().size_changed.connect(_on_viewport_size_changed)


func _process(delta: float) -> void:
	if auto_animate_time:
		_time += delta
		if _shader_material:
			_shader_material.set_shader_parameter("time", _time + time_offset)


func _on_viewport_size_changed() -> void:
	# アンカーを 0〜1 にしているので、特にサイズ変更処理は不要ですが、
	# 必要に応じて追加処理を入れられるようにフックを用意しておきます。
	pass


func _update_shader_params() -> void:
	if not _shader_material:
		return

	_shader_material.set_shader_parameter("intensity", intensity)
	_shader_material.set_shader_parameter("radius", radius)
	_shader_material.set_shader_parameter("center", center)
	_shader_material.set_shader_parameter("mix_amount", mix_amount)
	_shader_material.set_shader_parameter("enable_ripple", enable_ripple)
	_shader_material.set_shader_parameter("ripple_amplitude", ripple_amplitude)
	_shader_material.set_shader_parameter("ripple_frequency", ripple_frequency)
	_shader_material.set_shader_parameter("ripple_speed", ripple_speed)


func trigger_pulse(duration: float = 0.4, max_intensity: float = 1.2) -> void:
	##
	## 簡易的な「一瞬だけ歪ませる」パルス演出。
	## 例: ワープ開始時や被弾時に呼び出す。
	##
	## 使用例:
	##   $LensDistortion.trigger_pulse(0.3, 0.9)
	##
	if not is_inside_tree():
		return
	var tween := create_tween()
	tween.tween_property(self, "intensity", max_intensity, duration * 0.3).from(intensity)
	tween.tween_property(self, "intensity", 0.0, duration * 0.7)


func _get_shader_code() -> String:
	# Godot 4 の CanvasItem シェーダーで、ビューポート全体を歪ませる。
	# screen_texture に ViewportTexture を渡しているので、それを元に UV を変換します。
	return """
shader_type canvas_item;

uniform sampler2D screen_texture : source_color, repeat_disable;
uniform float intensity = 0.6;
uniform float radius = 1.0;
uniform vec2 center = vec2(0.5, 0.5);
uniform float mix_amount = 1.0;

uniform bool enable_ripple = false;
uniform float ripple_amplitude = 0.1;
uniform float ripple_frequency = 12.0;
uniform float ripple_speed = 4.0;
uniform float time = 0.0;

void fragment() {
	// 0〜1 のスクリーン座標(UV)
	vec2 uv = SCREEN_UV;

	// 中心からのベクトル
	vec2 dir = uv - center;
	float dist = length(dir);

	// 初期の歪み係数
	float distortion = 0.0;

	// 魚眼レンズ風の歪み
	if (dist < radius) {
		float norm = dist / radius; // 0〜1
		// norm^2 を使ったシンプルなバレルディストーション
		distortion = norm * norm * intensity;
	}

	// 衝撃波のリング状ゆらぎ
	if (enable_ripple && dist < radius) {
		float ripple_phase = dist * ripple_frequency - time * ripple_speed;
		float ripple = sin(ripple_phase) * ripple_amplitude;
		distortion += ripple;
	}

	// dir を正規化して、歪み量だけ外側へ(または内側へ)ずらす
	vec2 distorted_uv = uv + normalize(dir) * distortion;

	// 画面外に出た場合はクランプ(端が黒くならないように)
	distorted_uv = clamp(distorted_uv, vec2(0.0), vec2(1.0));

	vec4 original_color = texture(screen_texture, uv);
	vec4 distorted_color = texture(screen_texture, distorted_uv);

	// mix_amount で元の画面と歪んだ画面をブレンド
	COLOR = mix(original_color, distorted_color, mix_amount);
}
"""

使い方の手順

基本的な使い方を、プレイヤー操作型の 2D アクションゲームを例に説明します。

  1. コンポーネントスクリプトを用意する
    上記の GDScript を res://components/lens_distortion.gd などのパスで保存します。
    class_name LensDistortion を定義しているので、以後は「ノード追加」ダイアログから直接選べます。
  2. ゲームシーンにアタッチする
    例えば、2D アクションのメインシーンが以下のような構成だとします。
    MainScene (Node2D)
     ├── Player (CharacterBody2D)
     │    ├── Sprite2D
     │    └── CollisionShape2D
     ├── EnemySpawner (Node)
     ├── UI (CanvasLayer)
     │    └── Control
     └── LensDistortion (Node)   <-- ★ ここに追加
        
    • Godot エディタで MainScene を開く
    • ルート(ここでは MainScene)の子として + ノードを追加
    • 検索欄に「LensDistortion」と入力し、このコンポーネントを追加
    • インスペクタから intensityradius を調整して好みの見た目に
  3. イベントに合わせて歪みを発生させる
    例えばプレイヤーがワープポータルに入ったときに、画面を一瞬ぐにゃっとさせたい場合:
    
    # Player.gd の一部など
    func _on_enter_warp_portal() -> void:
    	var lens := get_tree().get_first_node_in_group("lens_distortion")
    	if lens:
    		lens.trigger_pulse(0.5, 1.2)
        

    上のコードを使う場合は、LensDistortion ノードを「グループ」に入れておくと便利です。

    MainScene (Node2D)
     ├── ...
     └── LensDistortion (Node)   <-- グループ "lens_distortion" に追加
        

    もちろん、普通にパスで取得しても OK です:

    
    var lens := get_node("/root/MainScene/LensDistortion")
    lens.trigger_pulse()
        
  4. 常時ゆらゆら揺らす/必殺技中だけ強くする
    例えば「ボス戦中は常に画面を少し歪ませておき、必殺技のときだけ強くする」ような制御も簡単です。
    
    # BossController.gd など
    @onready var lens: LensDistortion = $"../LensDistortion"
    
    func _ready() -> void:
    	# ボス戦開始時に、弱めの歪みを常時オン
    	lens.intensity = 0.2
    	lens.mix_amount = 0.6
    
    func _on_boss_special_attack() -> void:
    	# 必殺技開始時に一瞬だけ強く歪ませる
    	lens.trigger_pulse(0.7, 1.0)
        

別シーンでの使用例:動く床ステージ

動く床や重力反転ギミックがあるステージで、ギミック発動時に衝撃波を出したい場合:

MovingPlatformStage (Node2D)
 ├── Platforms (Node2D)
 │    ├── MovingPlatform1 (Node2D)
 │    └── MovingPlatform2 (Node2D)
 ├── Player (CharacterBody2D)
 ├── Camera2D
 └── LensDistortion (Node)

ステージスクリプト側で、波紋エフェクトをオンにしておきます。


# MovingPlatformStage.gd
@onready var lens: LensDistortion = $LensDistortion

func _ready() -> void:
	# 衝撃波(波紋)を有効化して、普段はオフ(mix_amount 0)
	lens.enable_ripple = true
	lens.mix_amount = 0.0
	lens.auto_animate_time = true

func _on_gimmick_activated() -> void:
	# ギミック起動時に、波紋を一気に広げてフェードアウト
	var tween := create_tween()
	lens.mix_amount = 1.0
	lens.intensity = 0.4

	tween.tween_property(lens, "radius", 0.3, 0.0).from(0.0)
	tween.tween_property(lens, "radius", 1.2, 0.6)
	tween.parallel().tween_property(lens, "mix_amount", 0.0, 0.6)

メリットと応用

LensDistortion をコンポーネントとして切り出すことで、継承や深いノード階層に頼らず、画面エフェクトを「後付け」できるようになります。

  • どのシーンにも同じコンポーネントをポン付け
    シーンルートの子に LensDistortion を 1 個足すだけで済むので、
    「エフェクト付きのベースシーン」を継承で増やす必要がありません。
  • シーン構造がスッキリ
    プレイヤーや敵などのゲームロジックと、画面エフェクトの責務を完全に分離できます。
    どのノードが何を担当しているかが明確になり、保守が楽になります。
  • 再利用性が高い
    メニュー画面でも、リザルト画面でも、ボス戦でも、同じコンポーネントを使い回しできます。
    将来「歪みアルゴリズムを変えたい」となっても、この 1 ファイルを書き換えるだけです。
  • テスト・デバッグがしやすい
    単体のシーンとして LensDistortion コンポーネントだけを開いて、
    インスペクタからパラメータをいじりながら見た目を調整する、といった作業がしやすくなります。

さらに、応用として:

  • 3D ゲームでも ViewportTexture を使えば同様のポストエフェクトが可能
  • プレイヤーの HP に応じて intensity を変え、「瀕死になるほど視界が歪む」演出
  • 水中エリアだけ、enable_ripple を常時オンにして「水中酔い」表現

改造案:画面外クリックで「波紋ショット」を飛ばす

例えば、マウスクリック位置を歪みの中心にして、一瞬だけ衝撃波を出す機能を追加するなら、こんなメソッドを足すだけでOKです。


func ripple_at_screen_position(screen_pos: Vector2, duration: float = 0.5) -> void:
	##
	## 画面上の任意の位置を中心に、波紋を一瞬だけ出す。
	## screen_pos: OS.window_coords などのスクリーン座標(ピクセル)
	##
	if not get_viewport():
		return

	var viewport_size: Vector2 = get_viewport().get_visible_rect().size
	if viewport_size.x == 0.0 or viewport_size.y == 0.0:
		return

	# ピクセル座標を 0〜1 の UV に変換
	center = Vector2(
		screen_pos.x / viewport_size.x,
		screen_pos.y / viewport_size.y
	)

	enable_ripple = true
	mix_amount = 1.0
	intensity = 0.3
	radius = 0.0

	var tween := create_tween()
	tween.tween_property(self, "radius", 1.2, duration)
	tween.parallel().tween_property(self, "mix_amount", 0.0, duration)

これを使えば、例えばタイトル画面でクリックした場所から「ぽちゃん」と波紋が広がる演出も、コンポーネント 1 つで完結します。
継承に頼らず、必要なシーンに必要なコンポーネントを足していくスタイルで、Godot プロジェクトをスッキリ保っていきましょう。