Godot 4で画面全体の色調を変えたいとき、よくやりがちなのが「ゲームごとに専用のポストエフェクト付きWorldEnvironmentシーンを作る」「カメラごとに違うシェーダーを仕込む」といった、けっこう重たい構成ですよね。
さらに、色覚多様性(色覚障害)に配慮した表示を実装しようとすると、
- 既存のシーン構成を崩したくないのに、ルートにWorldEnvironmentを追加しないといけない
- 複数のカメラがあるとき、どこにフィルタをかけるべきか悩む
- UIだけフィルタを変えたい / ゲーム画面だけ変えたい、などの切り替えが面倒
といった「設計がポストエフェクト前提になってしまう」問題が出てきます。
そこで今回は、どのシーンにも「ポン付け」できるコンポーネントとして、画面全体の色調を色覚多様性に配慮した表示に変換する 「ColorBlindFilter」コンポーネント を用意しました。
カメラやWorldEnvironmentを直接いじるのではなく、「ビューポートに後付けでフィルタをかける」コンポーネントとして設計しているので、継承ではなく合成(Composition)でサクッと差し替え・使い回しができます。
【Godot 4】色覚多様性に優しい画面フィルタ!「ColorBlindFilter」コンポーネント
このコンポーネントは以下の特徴を持ちます。
- 指定した
Viewport(通常はメインのゲーム画面)に色覚補正フィルタを適用 - 簡易的なシミュレーションモード(例: 1型/2型/3型色覚の見え方)と、補正モードを切り替え可能
- UIだけ別のフィルタをかける、なども「別Viewport + コンポーネント」で対応しやすい
- ゲーム中にオプションメニューからオン/オフ・モード変更が可能
実装は、ビューポートの出力を受け取ってフルスクリーン四角形に描画し、その上に色変換シェーダーを適用する、というシンプルな構成です。
つまり「何かのノードを継承する」のではなく、「カメラやルートシーンにただアタッチするだけ」で完結します。
フルコード:ColorBlindFilter.gd + シェーダ
コンポーネント本体は Node 派生のGDScriptとして実装し、内部で ColorRect + ShaderMaterial を自前で生成します。
(つまり、エディタ上では このスクリプトをアタッチするだけ でOKです)
# File: ColorBlindFilter.gd
extends Node
class_name ColorBlindFilter
## 画面全体に色覚補正フィルタを適用するコンポーネント。
## 任意のViewportに対して後付けでフィルタをかけられる。
@export var target_viewport_path: NodePath = ^"/root/Main/Viewport":
## フィルタをかけたいViewportへのパス。
## 通常はメインシーンのViewport、もしくは get_viewport() を使いたい場合は空文字でもOK。
get:
return target_viewport_path
set(value):
target_viewport_path = value
_update_viewport_reference()
@export_enum("Off", "Simulate_Protanopia", "Simulate_Deuteranopia", "Simulate_Tritanopia", "Correct_Protanopia", "Correct_Deuteranopia", "Correct_Tritanopia")
var mode: String = "Off":
## フィルタモード。
## - Off: 無効
## - Simulate_*: 各種色覚特性の見え方をシミュレート
## - Correct_*: それぞれの特性向けにコントラストを補正(簡易)
get:
return mode
set(value):
mode = value
_apply_mode()
@export_range(0.0, 1.0, 0.01)
var intensity: float = 1.0:
## フィルタの強さ。0.0で無効、1.0で最大。
get:
return intensity
set(value):
intensity = value
_update_shader_params()
@export var auto_bind_to_owner_viewport: bool = true:
## true の場合、owner(このノードを持つシーン)の Viewport に自動でバインド。
## ほとんどのケースでは true のままでOKです。
get:
return auto_bind_to_owner_viewport
set(value):
auto_bind_to_owner_viewport = value
if is_inside_tree():
_update_viewport_reference()
@export var shader_resource: Shader:
## カスタムの色覚補正シェーダを使いたい場合に差し替え可能。
## 未設定の場合は、デフォルトの内蔵シェーダを使用します。
get:
return shader_resource
set(value):
shader_resource = value
_build_material()
var _viewport: Viewport
var _color_rect: ColorRect
var _material: ShaderMaterial
func _ready() -> void:
# Viewportの参照を確立
_update_viewport_reference()
# 画面全体に被せるColorRectを構築
_create_fullscreen_quad()
# シェーダマテリアルを構築
_build_material()
# 初期パラメータ反映
_apply_mode()
_update_shader_params()
func _update_viewport_reference() -> void:
if not is_inside_tree():
return
if auto_bind_to_owner_viewport:
_viewport = get_viewport()
else:
if target_viewport_path == NodePath():
_viewport = get_viewport()
else:
var node := get_node_or_null(target_viewport_path)
if node is Viewport:
_viewport = node
else:
push_warning("ColorBlindFilter: target_viewport_path がViewportではありません。get_viewport()を使用します。")
_viewport = get_viewport()
if _color_rect:
# ColorRectをViewportのCanvasLayerにアタッチする
_attach_color_rect_to_viewport()
func _create_fullscreen_quad() -> void:
# すでに作られていれば何もしない
if _color_rect:
return
# 画面全体を覆うColorRectを作成
_color_rect = ColorRect.new()
_color_rect.name = "ColorBlindFilterQuad"
_color_rect.color = Color.WHITE
_color_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
_color_rect.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_color_rect.size_flags_vertical = Control.SIZE_EXPAND_FILL
_color_rect.anchor_left = 0.0
_color_rect.anchor_top = 0.0
_color_rect.anchor_right = 1.0
_color_rect.anchor_bottom = 1.0
_color_rect.offset_left = 0.0
_color_rect.offset_top = 0.0
_color_rect.offset_right = 0.0
_color_rect.offset_bottom = 0.0
_attach_color_rect_to_viewport()
func _attach_color_rect_to_viewport() -> void:
# ViewportのGUIルート(Controlツリー)に貼り付ける
if not _viewport:
return
var gui_root := _viewport.get_canvas_layer(0)
if gui_root == null:
# 通常のViewportでは、GUIルートはViewport自体(Controlではない)なので、
# 単純にこのノードの親にColorRectを載せる方法にフォールバックします。
if _color_rect.get_parent() != self:
add_child(_color_rect)
else:
if _color_rect.get_parent() != gui_root:
gui_root.add_child(_color_rect)
# 最前面に出す
_color_rect.z_index = 1024
func _build_material() -> void:
if not _color_rect:
return
if shader_resource == null:
# デフォルトの色覚補正シェーダを生成
var shader_code := _get_default_shader_code()
shader_resource = Shader.new()
shader_resource.code = shader_code
_material = ShaderMaterial.new()
_material.shader = shader_resource
_color_rect.material = _material
func _apply_mode() -> void:
if not _material:
return
# シミュレーション or 補正かどうかをシェーダに伝える
var mode_type := 0 # 0: off, 1: simulate, 2: correct
var cb_type := 0 # 0: none, 1: protan, 2: deutan, 3: tritan
match mode:
"Off":
mode_type = 0
cb_type = 0
"Simulate_Protanopia":
mode_type = 1
cb_type = 1
"Simulate_Deuteranopia":
mode_type = 1
cb_type = 2
"Simulate_Tritanopia":
mode_type = 1
cb_type = 3
"Correct_Protanopia":
mode_type = 2
cb_type = 1
"Correct_Deuteranopia":
mode_type = 2
cb_type = 2
"Correct_Tritanopia":
mode_type = 2
cb_type = 3
_:
mode_type = 0
cb_type = 0
_material.set_shader_parameter("u_mode_type", mode_type)
_material.set_shader_parameter("u_cb_type", cb_type)
# オフのときはColorRect自体を非表示にしてコスト削減
_color_rect.visible = mode != "Off" and intensity > 0.0
func _update_shader_params() -> void:
if not _material:
return
_material.set_shader_parameter("u_intensity", intensity)
func set_mode_from_string(new_mode: String) -> void:
## 外部(オプションメニューなど)から安全にモードを変更するためのヘルパ。
mode = new_mode
func toggle_enabled() -> void:
## On/Offをトグルする簡易メソッド。
if mode == "Off":
mode = "Correct_Deuteranopia" # 例: デフォルト補正モード
else:
mode = "Off"
func _get_default_shader_code() -> String:
# Godot 4 用のCanvasItemシェーダ
return """
shader_type canvas_item;
uniform float u_intensity : hint_range(0.0, 1.0) = 1.0;
uniform int u_mode_type = 0; // 0: off, 1: simulate, 2: correct
uniform int u_cb_type = 0; // 0: none, 1: protan, 2: deutan, 3: tritan
// sRGBを線形空間に近似変換(簡易)
vec3 srgb_to_linear(vec3 c) {
return pow(c, vec3(2.2));
}
vec3 linear_to_srgb(vec3 c) {
return pow(c, vec3(1.0 / 2.2));
}
// 簡易的な色覚シミュレーション行列(参考値ベースでの近似)
vec3 simulate_protanopia(vec3 c) {
// 赤の視細胞が欠損した見え方の近似
mat3 m = mat3(
0.0, 1.05118294, -0.05116099,
0.0, 1.0, 0.0,
0.0, 0.0, 1.0
);
return clamp(m * c, 0.0, 1.0);
}
vec3 simulate_deuteranopia(vec3 c) {
// 緑の視細胞が欠損した見え方の近似
mat3 m = mat3(
1.0, 0.0, 0.0,
0.9513092, 0.0, 0.04866992,
0.0, 0.0, 1.0
);
return clamp(m * c, 0.0, 1.0);
}
vec3 simulate_tritanopia(vec3 c) {
// 青の視細胞が欠損した見え方の近似
mat3 m = mat3(
1.0, 0.0, 0.0,
0.0, 1.0, 0.0,
-0.86744736, 1.86727089, 0.0
);
return clamp(m * c, 0.0, 1.0);
}
// ごく簡易な補正(Daltonization的なアイデアのライト版)
vec3 correct_colorblind(vec3 orig, vec3 simulated) {
// 誤差を取り出して、赤〜緑のコントラストを補う方向に再分配
vec3 error = orig - simulated;
// 赤と緑チャンネルを強調しつつ、青にも少し分配
vec3 correction = vec3(
error.r * 0.7 + error.g * 0.3,
error.g * 0.7 + error.r * 0.3,
error.b * 0.5
);
return clamp(orig + correction, 0.0, 1.0);
}
void fragment() {
vec4 src = texture(SCREEN_TEXTURE, SCREEN_UV);
if (u_mode_type == 0 || u_cb_type == 0 || u_intensity <= 0.0) {
COLOR = src;
return;
}
vec3 col = srgb_to_linear(src.rgb);
vec3 simulated = col;
if (u_cb_type == 1) {
simulated = simulate_protanopia(col);
} else if (u_cb_type == 2) {
simulated = simulate_deuteranopia(col);
} else if (u_cb_type == 3) {
simulated = simulate_tritanopia(col);
}
vec3 result = col;
if (u_mode_type == 1) {
// シミュレーション:単に置き換える
result = mix(col, simulated, u_intensity);
} else if (u_mode_type == 2) {
// 補正:誤差を利用してコントラストを調整
vec3 corrected = correct_colorblind(col, simulated);
result = mix(col, corrected, u_intensity);
}
COLOR = vec4(linear_to_srgb(result), src.a);
}
"""
使い方の手順
ここでは、2Dゲームのプレイヤーシーンと、UI専用Viewportを例にしながら手順を説明します。
手順①:スクリプトをプロジェクトに追加
res://scripts/ColorBlindFilter.gdといった場所に、上記コードを保存します。- Godotエディタを再読み込みすると、「ColorBlindFilter」クラスがインスペクタから選べるようになります。
手順②:メインシーンにコンポーネントとしてアタッチ
典型的な2Dゲームのシーン構成例:
Main (Node2D) ├── Player (CharacterBody2D) │ ├── Sprite2D │ ├── CollisionShape2D │ └── ... ├── Camera2D ├── CanvasLayer (UI) │ └── Control (各種UI) └── ColorBlindFilter (Node) <-- ★ このノードにスクリプトをアタッチ
Mainシーンを開き、子ノードとしてNodeを追加し、名前をColorBlindFilterにします。- そのノードに
ColorBlindFilter.gdをアタッチします。 auto_bind_to_owner_viewportはtrueのままでOKです(MainシーンのViewportに自動で紐づきます)。- 初期モードを
Correct_Deuteranopiaなどにしておくと、起動時から補正がかかった状態になります。
手順③:UIだけ別のフィルタをかけたい場合(応用)
例えば、「ゲーム画面全体にはDeuteranopia向け補正をかけるけど、UIはコントラスト強めの別シェーダを使いたい」といったケースでは、UI専用Viewportを用意します。
Root (Node) ├── GameViewport (SubViewport) │ └── Main (Node2D) │ ├── Player │ ├── Camera2D │ └── ColorBlindFilter (Node) ├── UICanvasLayer (CanvasLayer) │ └── UIViewportContainer (SubViewportContainer) │ └── UIViewport (SubViewport) │ └── UIRoot (Control) │ └── ColorBlindFilter (Node) <-- UI専用フィルタ └── RootCamera2D
- それぞれのViewportに対して
ColorBlindFilterを1つずつ置くイメージです。 auto_bind_to_owner_viewportをtrueにしておけば、どのシーンに置いても自動でそのシーンのViewportにひも付きます。
手順④:オプションメニューからモードを切り替える
ゲーム内の「アクセシビリティ」設定メニューなどから、モードを変更してみましょう。
例えば、UI側のスクリプトから以下のように呼び出せます。
# どこかのUIスクリプトから
func _on_color_mode_selected(index: int) -> void:
var filter := get_tree().get_first_node_in_group("color_blind_filter")
if filter and filter is ColorBlindFilter:
match index:
0:
filter.mode = "Off"
1:
filter.mode = "Correct_Protanopia"
2:
filter.mode = "Correct_Deuteranopia"
3:
filter.mode = "Correct_Tritanopia"
コンポーネント側に複雑な依存はなく、単にプロパティをいじるだけで完結するので、オプション画面やセーブデータとの連携も楽ですね。
メリットと応用
この「ColorBlindFilter」コンポーネントを使うことで、次のようなメリットがあります。
- シーン構造がスッキリ
「WorldEnvironmentをルートに置いて…」「カメラを継承して…」といった専用シーンを作らなくて済みます。
どのシーンにもColorBlindFilterを 1 ノード足すだけで完結します。 - 継承地獄からの解放
「ポストエフェクト付きカメラ」「フィルタ付きUI」といった専用クラスを増やさず、
既存のCamera2DやControlをそのまま使い続けられます。 - 複数Viewportの管理が簡単
ゲーム画面用とUI用のViewportを分けている場合でも、
それぞれのシーンにコンポーネントをアタッチするだけで、別々のフィルタを適用できます。 - アクセシビリティ設定の実装が楽
modeとintensityを保存・復元するだけで、ユーザーごとの色覚設定を再現できます。
「深いノード階層」や「特殊な継承ツリー」を避けて、必要な機能をコンポーネントとして後付けする構成にしておくと、
プロジェクトが大きくなってからも保守しやすいですし、チームメンバーにも説明しやすくなりますね。
改造案:ゲーム中にフェードインしながら補正を有効化する
例えば、チュートリアルの途中で「色覚補正をオンにしますか?」と聞いて、
オンにしたらふわっと画面が変わるような演出を入れたいときは、こんな感じのメソッドを追加できます。
func fade_to_mode(target_mode: String, duration: float = 0.5) -> void:
## 指定したモードに、指定時間かけてフェードインする。
## シンプルなTime-based補間で実装しています。
mode = target_mode
var t := 0.0
var start_intensity := intensity
var target_intensity := 1.0
while t < duration:
var dt := get_process_delta_time()
t += dt
var k := clamp(t / duration, 0.0, 1.0)
intensity = lerp(start_intensity, target_intensity, k)
await get_tree().process_frame
intensity = target_intensity
このように、ColorBlindFilter自体は「Viewportにフィルタをかける」ことだけに責務を絞り、
演出やゲームロジック側の都合は外部からメソッドを追加していく、というスタイルにすると、
コンポーネントの再利用性が高いまま、プロジェクトごとの味付けも簡単にできます。
継承より合成で、色覚多様性に優しいゲーム画面をどんどん増やしていきましょう。
