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を例にしながら手順を説明します。

手順①:スクリプトをプロジェクトに追加

  1. res://scripts/ColorBlindFilter.gd といった場所に、上記コードを保存します。
  2. Godotエディタを再読み込みすると、「ColorBlindFilter」クラスがインスペクタから選べるようになります。

手順②:メインシーンにコンポーネントとしてアタッチ

典型的な2Dゲームのシーン構成例:

Main (Node2D)
 ├── Player (CharacterBody2D)
 │    ├── Sprite2D
 │    ├── CollisionShape2D
 │    └── ...
 ├── Camera2D
 ├── CanvasLayer (UI)
 │    └── Control (各種UI)
 └── ColorBlindFilter (Node)  <-- ★ このノードにスクリプトをアタッチ
  1. Main シーンを開き、子ノードとして Node を追加し、名前を ColorBlindFilter にします。
  2. そのノードに ColorBlindFilter.gd をアタッチします。
  3. auto_bind_to_owner_viewporttrue のままでOKです(MainシーンのViewportに自動で紐づきます)。
  4. 初期モードを 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_viewporttrue にしておけば、どのシーンに置いても自動でそのシーンの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」といった専用クラスを増やさず、
    既存の Camera2DControl をそのまま使い続けられます。
  • 複数Viewportの管理が簡単
    ゲーム画面用とUI用のViewportを分けている場合でも、
    それぞれのシーンにコンポーネントをアタッチするだけで、別々のフィルタを適用できます。
  • アクセシビリティ設定の実装が楽
    modeintensity を保存・復元するだけで、ユーザーごとの色覚設定を再現できます。

「深いノード階層」や「特殊な継承ツリー」を避けて、必要な機能をコンポーネントとして後付けする構成にしておくと、
プロジェクトが大きくなってからも保守しやすいですし、チームメンバーにも説明しやすくなりますね。

改造案:ゲーム中にフェードインしながら補正を有効化する

例えば、チュートリアルの途中で「色覚補正をオンにしますか?」と聞いて、
オンにしたらふわっと画面が変わるような演出を入れたいときは、こんな感じのメソッドを追加できます。


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にフィルタをかける」ことだけに責務を絞り
演出やゲームロジック側の都合は外部からメソッドを追加していく、というスタイルにすると、
コンポーネントの再利用性が高いまま、プロジェクトごとの味付けも簡単にできます。

継承より合成で、色覚多様性に優しいゲーム画面をどんどん増やしていきましょう。