Godotで画面エフェクトを入れようとすると、つい「シーンをまたいだ巨大な継承ツリー」とか「各シーンごとにCameraにシェーダーを直書き」みたいな構成になりがちですよね。
シーンごとに同じようなコードをコピペしたり、UIシーンとゲームシーンで処理が分かれてしまったり……「どこで画面エフェクトを管理しているのか」がだんだん分からなくなってきます。
そこで今回は、「画面全体の解像度を落としてドット絵風にする」モザイクエフェクトを、1つの独立したコンポーネントとして実装してみましょう。
どのシーンにもサクッとアタッチできて、シーン構造を汚さず、コードも一箇所にまとまる構成です。
【Godot 4】一発で画面をドット化!「PixelateEffect」コンポーネント
今回作る PixelateEffect は、
- 画面全体にモザイク(ピクセル化)エフェクトをかける
- イベント時や画面遷移時に、なめらかにオン/オフできる
- どのシーンにも簡単に再利用できる
という、コンポーネント指向な「画面ポストエフェクト」です。
内部的には ViewportTexture + ShaderMaterial を使って、現在の画面を一度オフスクリーンに描画し、それをピクセル化して再描画する構造にしています。
フルコード:PixelateEffect.gd
extends CanvasLayer
class_name PixelateEffect
## 画面全体をピクセル化(モザイク)表示するコンポーネント
## ・シーンのどこかに1つ置くだけでOK
## ・他のシーンにも簡単に再利用可能
##
## 仕組み:
## RootViewport → OffscreenViewport に描画 → Shader でピクセル化 → フルスクリーンQuadで表示
@export_category("Pixelate Effect Settings")
## ピクセル化の強さ(ピクセルサイズ)
## 値が大きいほど荒く(モザイク強く)なります
@export_range(1.0, 64.0, 1.0, "or_greater")
var pixel_size: float = 8.0:
set(value):
pixel_size = max(value, 1.0)
_update_shader_params()
## エフェクトの有効/無効
## true にするとピクセル化を適用します
@export var enabled: bool = true:
set(value):
enabled = value
_update_shader_params()
## トランジション用:0.0 = 元の画面、1.0 = 完全ピクセル化
## アニメーションでここを操作すると、なめらかに切り替えられます
@export_range(0.0, 1.0, 0.01)
var intensity: float = 1.0:
set(value):
intensity = clamp(value, 0.0, 1.0)
_update_shader_params()
@export_category("Runtime Control")
## 自動で現在のRootViewportサイズに追従するかどうか
@export var auto_resize_to_viewport: bool = true
## デバッグ用:ビューポートの解像度を手動で指定したい場合
@export var override_viewport_size: Vector2i = Vector2i.ZERO:
set(value):
override_viewport_size = value
_update_viewport_size()
## 内部ノード
var _viewport: Viewport
var _quad: ColorRect
## シェーダー
var _shader_material: ShaderMaterial
func _ready() -> void:
# CanvasLayerは2D画面の最前面に描画されるので、ここにフルスクリーンQuadを置く
_init_viewport()
_init_quad()
_init_shader()
_update_viewport_size()
_update_shader_params()
# 画面サイズ変更に追従(ウィンドウリサイズなど)
if auto_resize_to_viewport:
get_viewport().size_changed.connect(_on_root_viewport_resized)
func _init_viewport() -> void:
# 画面をオフスクリーン描画するためのViewportを作成
_viewport = Viewport.new()
_viewport.name = "PixelateViewport"
_viewport.render_target_update_mode = Viewport.UPDATE_ALWAYS
_viewport.usage = Viewport.USAGE_2D
_viewport.handle_input_locally = false
_viewport.disable_3d = true
_viewport.disable_input = true
# ルートViewportの子にして、シーン全体をここにミラー描画させる
# ※ 注意: 実際のゲームシーンは RootViewport にぶら下がっている想定
get_tree().root.add_child(_viewport)
_viewport.owner = null # シーン保存の対象外にしておく
func _init_quad() -> void:
# オフスクリーン描画結果をフルスクリーンで表示するQuad
_quad = ColorRect.new()
_quad.name = "PixelateQuad"
_quad.color = Color.WHITE
_quad.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_quad)
# Layoutをフルスクリーンに設定
_quad.anchor_left = 0.0
_quad.anchor_top = 0.0
_quad.anchor_right = 1.0
_quad.anchor_bottom = 1.0
_quad.offset_left = 0.0
_quad.offset_top = 0.0
_quad.offset_right = 0.0
_quad.offset_bottom = 0.0
func _init_shader() -> void:
# ViewportTextureを受け取ってピクセル化するシェーダー
var shader_code := """
shader_type canvas_item;
// オフスクリーンに描画された画面
uniform sampler2D screen_tex : hint_albedo;
// ピクセル化の強さ(ピクセルサイズ)
uniform float pixel_size = 8.0;
// 0.0 = 元の画面, 1.0 = 完全ピクセル化
uniform float intensity = 1.0;
// 画面解像度(ピクセル化計算用)
uniform vec2 screen_size = vec2(1280.0, 720.0);
vec2 snap_to_pixel(vec2 uv, float size) {
// uv を画面ピクセル座標に変換してから、size ごとにまとめて再度 uv に戻す
vec2 pixel_pos = uv * screen_size;
pixel_pos = floor(pixel_pos / size) * size + size * 0.5;
return pixel_pos / screen_size;
}
void fragment() {
vec2 uv = UV;
// 元の色
vec4 original_color = texture(screen_tex, uv);
// ピクセル化した色
vec2 pixel_uv = snap_to_pixel(uv, max(pixel_size, 1.0));
vec4 pixelated_color = texture(screen_tex, pixel_uv);
// intensity でミックス
COLOR = mix(original_color, pixelated_color, clamp(intensity, 0.0, 1.0));
}
"""
var shader := Shader.new()
shader.code = shader_code
_shader_material = ShaderMaterial.new()
_shader_material.shader = shader
# Viewport の描画結果をテクスチャとして渡す
_shader_material.set_shader_parameter("screen_tex", _viewport.get_texture())
_quad.material = _shader_material
func _update_viewport_size() -> void:
if not is_instance_valid(_viewport):
return
var size_to_use: Vector2i
if override_viewport_size != Vector2i.ZERO:
size_to_use = override_viewport_size
else:
size_to_use = get_viewport().size
_viewport.size = size_to_use
# シェーダーに画面サイズを渡す
if _shader_material:
_shader_material.set_shader_parameter("screen_size", Vector2(size_to_use.x, size_to_use.y))
func _update_shader_params() -> void:
if not _shader_material:
return
_shader_material.set_shader_parameter("pixel_size", pixel_size)
_shader_material.set_shader_parameter("intensity", intensity if enabled else 0.0)
# 有効/無効に応じてQuadの表示を切り替え
_quad.visible = enabled or intensity > 0.0
func _on_root_viewport_resized() -> void:
if auto_resize_to_viewport:
_update_viewport_size()
# ---------------------------------------------------------
# 公開API:ゲーム側から呼び出すための補助メソッド
# ---------------------------------------------------------
## ピクセル化エフェクトを有効化(オプションで即時 or アニメーション)
func enable_effect(immediate: bool = true, target_intensity: float = 1.0, duration: float = 0.3) -> void:
enabled = true
if immediate or duration <= 0.0:
intensity = target_intensity
return
# Tweenでなめらかに切り替え
var tween := create_tween()
tween.tween_property(self, "intensity", target_intensity, duration).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
## ピクセル化エフェクトを無効化(オプションで即時 or アニメーション)
func disable_effect(immediate: bool = true, duration: float = 0.3) -> void:
if immediate or duration <= 0.0:
intensity = 0.0
enabled = false
return
var tween := create_tween()
tween.tween_property(self, "intensity", 0.0, duration).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
tween.finished.connect(func():
enabled = false
)
## 一瞬だけ「ピクセル化 → 元に戻す」フラッシュエフェクト
func flash(duration: float = 0.4, peak_intensity: float = 1.0) -> void:
enabled = true
intensity = 0.0
var tween := create_tween()
var half := duration * 0.5
tween.tween_property(self, "intensity", peak_intensity, half).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT)
tween.tween_property(self, "intensity", 0.0, half).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN)
tween.finished.connect(func():
enabled = false
)
使い方の手順
ここからは、実際にシーンに組み込む手順を見ていきましょう。基本的には「どこかのシーンに 1 個置くだけ」です。
手順①:スクリプトを保存する
PixelateEffect.gdをプロジェクトの好きな場所(例:res://components/PixelateEffect.gd)に保存します。- Godotエディタでスクリプトを開き、エラーが出ていないことを確認します。
手順②:グローバルなUI/Rootシーンにアタッチする
画面全体にかけたいので、ゲームの最上位(UIやRootシーン)にこのコンポーネントを置くのがおすすめです。
例:ゲーム全体を管理する Main シーンがある場合:
Main (Node) ├── GameRoot (Node2D) ← 各ステージやプレイヤーなどはここ以下にロード ├── UI (CanvasLayer) └── PixelateEffect (CanvasLayer) ← このコンポーネントを追加
Mainシーンを開く- 子ノードとして
CanvasLayerを追加し、「PixelateEffect.gd」をアタッチ - インスペクタで以下を調整:
pixel_size: 8~16 くらいが分かりやすいモザイク感enabled: 開始時にオフにしたいなら falseintensity: 初期状態でどのくらい効かせるか(0~1)
手順③:プレイヤーやイベントから呼び出す
たとえば「プレイヤーがダメージを受けたときに、画面を一瞬ドット化する」例です。
Player (CharacterBody2D) ├── Sprite2D ├── CollisionShape2D └── Hurtbox (Area2D)
プレイヤースクリプトから PixelateEffect を探して呼び出します:
# Player.gd(例)
extends CharacterBody2D
func _on_hurtbox_hit():
# シーンツリーから PixelateEffect を探す
var pixelate := get_tree().get_first_node_in_group("pixelate_effect")
if pixelate:
pixelate.flash(0.4, 1.0) # 0.4秒かけて「ドット化→戻る」
上のコードが動くようにするため、PixelateEffect ノードにグループを設定しておきましょう:
- エディタで
PixelateEffectノードを選択 - 「ノード」タブ → グループ →
pixelate_effectを追加
これで、どのシーンからでも簡単にアクセスできます。
手順④:画面遷移で使う(フェードイン/アウト風)
今度は「ステージ切り替え時に、画面をドット化しながらフェードアウト → 次のステージをロード → 戻す」という演出をしてみます。
Main (Node) ├── GameRoot (Node2D) ├── UI (CanvasLayer) └── PixelateEffect (CanvasLayer)
Main.gd の例:
# Main.gd
extends Node
@onready var pixelate: PixelateEffect = $PixelateEffect
@onready var game_root: Node = $GameRoot
func change_level(scene_path: String) -> void:
# 1) 画面をピクセル化しながらフェードアウト
await pixelate.enable_effect(false, 1.0, 0.4) # duration=0.4秒
# 2) 現在のステージをクリアして新しいステージをロード
for child in game_root.get_children():
child.queue_free()
var new_scene := load(scene_path).instantiate()
game_root.add_child(new_scene)
# 3) ピクセル化を解除(フェードイン)
await pixelate.disable_effect(false, 0.4)
このように、画面遷移ロジックと見た目のエフェクトをきれいに分離できるのがコンポーネント方式の良いところですね。
メリットと応用
PixelateEffect をコンポーネントとして切り出しておくと、次のようなメリットがあります。
- シーン構造がシンプル
各ステージシーンやUIシーンに「シェーダー付きCamera」や「特殊なViewport」を仕込む必要がありません。
画面全体のエフェクトはMainシーンなど、一箇所にまとめておくのがスッキリします。 - 再利用性が高い
別プロジェクトにもPixelateEffect.gdと対応シーンをコピペするだけで持っていけます。
「このゲームでも前のゲームと同じ演出を使いたい」となったときに本領発揮ですね。 - テストがしやすい
単体で動かせるので、「エディタ上でPixelateEffectだけをいじって挙動を確認する」といったデバッグがやりやすくなります。 - 継承地獄を避けられる
「すべてのシーンはBaseSceneを継承し、そこにポストエフェクト処理を書く」みたいな構造にしなくて済みます。
継承ではなく、必要なシーンに必要なコンポーネントをアタッチするだけという構成に寄せていけます。
応用としては、
- 特定のイベント(必殺技発動、タイムストップ、ポーズメニューなど)時に画面をドット化
- タイトル画面 → ゲーム開始時の演出として、徐々に解像度が上がるような表現
- 低解像度モード(レトロ風グラフィック設定)として常時オンにする
など、いろいろな「見せ方」に使えます。
改造案:時間経過でじわじわ解像度を上げる
ゲーム開始時に「最初はガチガチのモザイク → 徐々にクリアになっていく」演出を入れたい場合の簡単な改造例です。
## PixelateEffect.gd に追加できるサンプル関数
## ゲーム開始演出用:モザイク強 → 弱(またはOFF)にする
func reveal_over_time(duration: float = 1.5, start_pixel_size: float = 32.0, end_pixel_size: float = 4.0) -> void:
enabled = true
pixel_size = start_pixel_size
intensity = 1.0
var tween := create_tween()
tween.tween_property(self, "pixel_size", end_pixel_size, duration).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT)
tween.finished.connect(func():
# 好みで完全にOFFにするならここで
# intensity = 0.0
# enabled = false
pass
)
これを Main.gd の _ready() などから呼べば、ゲーム開始時に自動で「ドットから鮮明になる」演出が入ります。
こんな感じで、画面エフェクトも1コンポーネント=1責務として切り出しておくと、あとからいくらでも遊べるようになります。
「継承より合成」の流れで、他のポストエフェクト(ブラー、色調変更など)も同じパターンでコンポーネント化していくと、プロジェクト全体がかなり見通し良くなりますよ。
