🖌️

お絵かきソフト「Aquamarine Painter」を作ってみた

2024/12/14に公開1

はじめに

この記事は グラフィックス全般 Advent Calendar 2024 の14日目の記事です。

今年の夏頃に私が制作した「Aquamarine Painter」というお絵かきツールについて技術面を含めて軽く紹介する記事を書いてみようと思います。

screenshot
Aquamarine Painter のスクリーンショット

この グラフィックス全般 Advent Calendar 2024 には他にもお絵かきツールを作成していらっしゃる方の記事があるようです。合わせて読んでみると面白いかもしれません。
https://qiita.com/warotarock/items/13b2ff8839b7face3695
https://qiita.com/warotarock/items/0ea630c097c26860dd89
https://qiita.com/warotarock/items/bb3260947159ac7b2b66
こちらは適当に作って終わらせた私とは違い、長年アップデートを続けているちゃんとしたプロダクトのようですよ。

Aquamarine Painter とは

2024年の夏頃に私が開発したベクター系のお絵かきソフトです。
Godot Engine製で、PPW Curvesというスプライン曲線を採用したツールとなっています。

screenshot
Aquamarine Painter のロゴ

GitHubのリポジトリはこちらです。私が書いたコード部分についてはMITライセンスで公開しています。
https://github.com/MatchaChoco010/AquamarinePainter

このツールはある種のプロトタイプとして開発しているので、あまり読みやすいコードではないですが。。。

Aquamarine Painter の特徴

Aquamarine Painterは次のような特徴を備えています。

特殊なスプライン曲線を採用している

PPW Curvesという特殊なスプライン曲線を採用しています。PPW Curvesについて詳しくは後述しますが、大雑把には次のような特徴の曲線です。

  • コントロールポイントを全て通る曲線が描けます
  • コントロールポイントは曲線の曲率極大な位置に置かれていて操作しやすいようです
  • さらに曲線の尖り具合や曲がり方の微妙な調整をphipsiweightというパラメータで調整できます
  • ベジェ曲線のタンジェントのコントローラーで曲線の調整をするのとは違った操作感です

マテリアルシステム

異なるパスに同じ色を割り当てて一括で色を編集できます。

screenshot
マテリアルのリスト

同じ色を持つパーツが複数のパスに分かれている際に、パス毎に色を割り当てているタイプのツールでは色の一括変更が若干難しいですが、このツールではマテリアルを使って色の割り当てを一つ間接的にすることで一括で同じ色を使っているパスの色を変更できます。

お絵かきソフトっぽいレイヤーシステム

よくあるお絵かきソフトっぽいレイヤーのシステムを実装してあります。

screenshot
よくあるレイヤーシステム

具体的には以下のような操作が実装されています。

  • レイヤーフォルダ
  • レイヤー合成モード
  • レイヤーの透明度
  • 下のレイヤーでクリッピング

screenshot
レイヤーのフォルダーやクリッピングなどの様子

Aquamarine Painter の使い方

描画ツール でキャンバスをクリックしていくことで線を描くことが出来ます。
線はコントロールポイントを持っており、操作ツール でコントロールポイントをドラッグすることで線の形状を編集できます。

screenshot
コントロールポイントが表示されている様子

また、上記の操作ツールに加えて、曲線の微妙な曲がり具合を 調整ツール を利用することで以下のようなパラメータの調整ができます。

操作 gif
コントロールポイントを左右ドラッグ weight
コントロールポイントの間のセグメントを上下にドラッグ phi
コントロールポイントの間のセグメントを左右にドラッグ psi

キーボードショートカット

キー 機能
Ctrl + Z アンドゥ
Ctrl + Shift + Z リドゥ
Ctrl + C 選択中のマテリアル or レイヤーのコピー
Ctrl + V ペースト
1 描画ツール
2 操作ツール
3 調整ツール
4 枠内に収める
5 拡大率を100%に戻す
6 左右反転
Space コントローラーの一時非表示

実装機能の選定

私はお絵かき自体は経験が浅く、まだ狙った線をきれいに引くのが苦手です。普段お絵かきをする際は何度も線を引いてはCtrl-Zを押してアンドゥするというのを繰り返しています。

さらに課題となるのは、ある程度線画を描き進めてみてからやっぱり全体のバランスが変な気がするとなって線を修正したくなることです。ラフでバランスを取って清書してみて、清書が一通り終わってみてからやっぱりもうちょっと調整したいとなって、もう一度ラフに戻ってバランスを調整し直してということを繰り返しているのですが、どうにも非効率なのが気になっていました。

後から線を調整するというのはベクター系のツールの強みです。ベクター系のお絵かきツールで使いやすいものがあればそれで上記の問題は解決しそうです。

また、私の描きたいイラストは一旦はアニメ塗りのイラストで、細かいペンのタッチなどは必要なく、どちらかというときれいに塗りつぶしができる方が重要です。これもベクター系のツールを選ぶ大きな理由の一つです。

しかし既存のベクター系のツールの挙動があまり個人的に好みではありませんでした。

CLIP STUDIO PAINTのベクターレイヤー

普段利用しているCLIP STUDIO PAINTのベクターレイヤーは個人的にあまり好みの挙動をしてくれませんでした。

ベクターレイヤーはあるものの、ベクターレイヤーで普通に線を描くとコントロールポイントの点列が多すぎて後からの調整が難しいです。CLIP STUDIO PAINT的には線の上から別のツールでなぞることで線の太さや線の微調整などができるようなGUIを提供していますが、これでは線を引くのが苦手な人にとっては何度も線を引き直すような操作が必要であまりもとの問題の解決になっていません。

CLIP STUDIO PAINTには連続曲線ツールもあって、こちらをベクターレイヤーで使えば、コントロールしやすい曲線を作ることもできるようですが、これがCLIP STUDIO PAINTの想定するベクターレイヤーのメインの使い方かどうかはわかりません。

また、塗りつぶしなどの挙動がどちらかというとラスター的なツールに寄っているような気がします。

Adobe Illustrator

ベクター系のイラストツールというと多分このツールが一番有名な気がします。

個人的な感想として、レイヤー周りの挙動がなんかよくあるお絵かきツールとは異なっていて独特な気がします。もう少し普通のお絵かきツールっぽくレイヤー周りを使いたいというのがありました。

また、パスに色を割り当てて絵を書くのですが、複数のパスに同じ色を割り当てたい場合や、一括で同じ色のパーツを変えたいというのが標準機能でどうやるのかちょっとよくわかっていません。きっと高機能なツールなのでそのようなことをやる方法はありそうですが……。

そこで今回はベクター系のツールを以下の機能にこだわって作ってみることにしました。

後から線の調整ができる

ベクター系のお絵かきツールで、後からコントロールポイントを操作して線を調整できるツールを作ります。コントロールポイントが少なく操作しやすいツールであるとなお良いです。

色の割り当てが一括で変更できる

個人的にはアニメ塗りのお絵かきをしていると複数パスに同じ色を割り当てたい場合が多かったです。そのような用途で使いやすいツールを作ります。

お絵かきツールで馴染みのあるレイヤーシステム

CLIP STUDIO PAINTなどのメジャーなお絵かきツールっぽいレイヤーシステムを作ります。レイヤーの透明度やレイヤーフォルダー、下のレイヤーでクリッピングなどの仕組みを用意します。


上記の機能は Aquamarine Painter に一通り実装されています。

PPW Curves

次に Aquamarine Painter の実装にあたって採用したスプライン曲線について紹介します。

Aquamarine Painterはベクターのスプライン曲線として全面的にPPW Curvesという曲線を利用しています。

screenshot
https://www.jstage.jst.go.jp/article/transinf/E105.D/10/E105.D_2022PCP0006/_pdf

PPW Curvesは2022年に発表された比較的新しい曲線です。PPW Curvesは次のような特徴を持っています。

  • コントロールポイントをすべて通る曲線
  • C2連続の滑らかな曲線
    • ただしコントロールポイントやセグメントのパラメータで折れ線も可能
  • コントロールポイントの操作の影響範囲がローカルである
  • おおよそ曲率極大の位置にコントロールポイントがある
  • 形状の曲がり方の微調整が可能
    • Psi、Phi、Weightというパラメータで調整可能
      • このパラメータの頭文字を取ってPPW Curvesという名前だそうです。

既存のコントロールポイントを曲率極大で通る曲線である κ-Curves や その有理版の κ-Curves それから2020年にSIGGRAPHで発表された「A Class of C2 Interpolating Splines」などと比べても、少ないコントロールポイントで様々な形状が描画できることが論文中で例示されています。

screenshot

今回この曲線を選んだ理由は、比較的新しいスプライン曲線を触ってみたいという技術的興味と、ベジェ曲線のコントロールは苦手だったためです。ベジェ曲線のタンジェントのコントローラーで曲線の調整をするより、すべての点を通って滑らかになってくれる曲線の方が操作しやすいのではないかと考えました。

PPW Curvesを見つけた経緯

PPW Curvesは割とマイナーなスプライン曲線だと思います。
私がこの曲線を知ったのは、私が大学院に修士の学生として研究室に所属していたときに当時一緒に所属していた方々がPPW Curvesの論文の著者であったためです。

Godot Engine での実装

次に Aquamarine Painter の実装について軽く紹介します。

Aquamarine Painter の実装にはGodot Engineを利用しています。Godot Engineの4.2で開発を始めて、途中で4.3がリリースされたのでそちらに切り替えました。

Godot Engine 採用の理由

ツールの作成にあたってゲームエンジンを利用することにしたのは、ウィジェットを並べて作るようなよくあるGUIフレームワークでペイントソフトみたいな高度なグラフィクスを扱うツールを作れるのかよくわかっておらず、ゲームエンジンならshaderとか書けるし行けるかなと考えてのことです。

特にGodot Engineを選んだ理由は、最近盛り上がっているようなので触ってみたかったというのと、あとはGodot EngineのGUI自体がGodotで作られているという話を聞いてGUIツールが作りやすいかもという期待を込めてのことでした。わりと見切り発車。

PPW Curves の実装

PPW Curvesを細かい折れ線にしたり、折れ線をさらにTriangulateしてポリゴンデータにするのには、大量の頂点を扱うため処理速度が必要そうです。そのため勉強も兼ねてRustでGDExtensionを書いてみました。

https://github.com/godot-rust/gdext
https://godot-rust.github.io/

本当にGDExtension化する必要があったかどうかの計測などは行っていません。ただ、RustでGodotを扱うコードを書いてみたかったというのがRustのGDExtensionを使ってみた一番の理由です。

RustでGDExtensionを書く体験はとても良いものでした。Rust Analyzerによる型の補完なども働いてくれて、スムーズにコードを書くことができました。

PPW Curves の実装そのものは、Web上に公開されている既存のUnity実装をかなり参考にして、ほとんどそのままRustに置き換えた感じで実装しています。PPW Curvesの実装にあたってはニュートン法などを利用して最適化を回す部分があるのですが、そこらへんの初期値をヒューリスティックで決める方法やパラメータまわりの実装などもそのUnityの実装を参考にしました。

https://github.com/Rijicho/ppw

PPW Curvesを折れ線に変換した後、塗りつぶしのポリゴンを作成するためのTriangulateに次のRustのライブラリを使いました。

https://github.com/iShape-Rust/iTriangle
https://ishape-rust.github.io/iShape-js/triangle/triangle.html

上記ライブラリにはいくつかバグや使いにくい仕様などがあり、PRを送って取り込んでもらったりもしました。

https://github.com/iShape-Rust/iFloat/pull/1
https://github.com/iShape-Rust/iFloat/pull/2

GUI

dock

GUIのdockの作成にはこちらのアドオンを利用しました。

https://github.com/gilzoide/godot-dockable-container

パネルの幅を狭くしたときに中の要素が重なって被ってしまうなどの問題があり4.3での利用は完璧ではない感じは若干ありますが十分使えます。

レイヤー

レイヤーフォルダー周りの実装はだいぶ雑に作ったので見通しの悪いコードになってしまいました。

レイヤーをドラッグ・アンド・ドロップするときに、フォルダの子レイヤーも一緒にドラッグする必要があったり、ドラッグ先のフォルダの階層構造とかその開閉状況も適切に扱う必要があり、単純なReorderableなリストというわけにはいきません。

screenshot

今回、雑に動くソフトを作りたかったのがあって、レイヤーリストの部分の挙動を適切に切り出したクラスとかも作らず雑にレイヤーのデータを直接操作しているので、かなり見通しの悪いコードになっています。親レイヤーを選択したときにフォルダが折りたたまれているかでも挙動を変えたりとか、中々ダーティーな挙動が多くなっています。次お絵かきツールを作るときにはもう少しこのレイヤー周りのGUIを整理することを考えると良さそう。

カラーピッカー

カラーピッカーはGodotにデフォルトで入っているものを利用しました。

screenshot

エンジンで使われているようなGUIがデフォルトでいろいろはいっているのはGodotでツール開発をする良いところですね。

screenshot
Godot組み込みのColorPickerノードを配置している

グラデーションツール

グラデーションツールは自作しました。

screenshot

Godotエンジンにも組み込みのグラデーション洗濯用のツールは存在するようですが、エンジンで使われているグラデーションツールにユーザーがアクセスする方法が無いようなので適当なものを自作しています。

screenshot

canvasの描画周り

canvasの合成モードやレイヤーフォルダを考慮した合成はRenderingServer.call_on_render_thread()を利用して自前でコンピュートやグラフィクスのパイプラインを用意して実装しています。

CanvasGroupについて

Godotには複数の2Dスプライトの描画をまとめるための機能としてCanvasGroupというノードがあります。一見するとこのノートを使うことで、レイヤーをCanvasLayerとしてフォルダをCanvasGroupとすると、複数のレイヤーをまとめたものをまとめて透明度を変えたり合成したり出来そうに見えます。

https://docs.godotengine.org/ja/4.x/classes/class_canvasgroup.html

しかし、CanvasGroupノードではネストできないという壁がありました。

https://github.com/godotengine/godot/issues/74175

また実行時の性能的にもバックバッファに毎回コピーしたりというのが制御できないのは厳しそうなので、自前で描画周りを実装しています。

パスの描画

ポリゴン化したパスの塗りを描画するために、RenderingDevice.render_pipeline_createでRenderPipelineを作成しています。以下はその部分のコードの抜粋です。

path_paint_compositor.gd
...

func _init() -> void:
	texture = Texture2DRD.new()
	RenderingServer.call_on_render_thread(_initialize_compositor)

...


func _initialize_compositor() -> void:
	var rd := RenderingServer.get_rendering_device()

	var attachments: Array[RDAttachmentFormat] = []
	var attachment_format := RDAttachmentFormat.new()
	attachment_format.set_format(RenderingDevice.DATA_FORMAT_R8G8_SNORM)
	attachment_format.set_samples(RenderingDevice.TEXTURE_SAMPLES_1)
	attachment_format.usage_flags = RenderingDevice.TEXTURE_USAGE_SAMPLING_BIT + \
		RenderingDevice.TEXTURE_USAGE_COLOR_ATTACHMENT_BIT + \
		RenderingDevice.TEXTURE_USAGE_STORAGE_BIT + \
		RenderingDevice.TEXTURE_USAGE_CAN_UPDATE_BIT + \
		RenderingDevice.TEXTURE_USAGE_CAN_COPY_TO_BIT + \
		RenderingDevice.TEXTURE_USAGE_CAN_COPY_FROM_BIT
	attachments.push_back(attachment_format)
	_rasterize_framebuffer_format = rd.framebuffer_format_create(attachments)

	var vertex_attrs := [RDVertexAttribute.new()]
	vertex_attrs[0].format = RenderingDevice.DATA_FORMAT_R32G32_SFLOAT
	vertex_attrs[0].location = 0
	vertex_attrs[0].stride = 4 * 2
	_rasterize_vertex_format = rd.vertex_format_create(vertex_attrs)

	var blend := RDPipelineColorBlendState.new()
	var blend_attachment := RDPipelineColorBlendStateAttachment.new()
	blend_attachment.write_r = true
	blend_attachment.write_g = true
	blend_attachment.write_b = true
	blend_attachment.enable_blend = false
	blend.attachments.push_back(blend_attachment)

	var rasterize_render_shader_file: RDShaderFile = load("res://shaders/composite/rasterize.glsl")
	var rasterize_render_shader_spirv := rasterize_render_shader_file.get_spirv()
	_rasterize_render_shader = rd.shader_create_from_spirv(rasterize_render_shader_spirv)
	_rasterize_render_pipeline = rd.render_pipeline_create(
		_rasterize_render_shader,
		_rasterize_framebuffer_format,
		_rasterize_vertex_format,
		RenderingDevice.RENDER_PRIMITIVE_TRIANGLES,
		RDPipelineRasterizationState.new(),
		RDPipelineMultisampleState.new(),
		RDPipelineDepthStencilState.new(),
		blend)

  ...

やっていることとしてはattachmentsでアタッチするテクスチャのフォーマットとusageのフラグを指定して、頂点シェーダーの頂点のフォーマットを指定して、ブレンドモードを指定して、シェーダーを読み込んで、パイプラインを作るという感じです。

VulkanやWebGPUなどのAPIを触ったことがあれば、パイプラインに必要な構造体を詰め込んでパイプラインを作るというよく見慣れた構造であることがわかると思います。Godotのレンダリング周りのクラスはVulkanなどの現代的なAPIをきれいにラップしており、それらを利用したことがあれば素直に使えると思います。

つぎにこのパイプラインを利用して実際に描画している部分の抜粋です。

path_paint_compositor.gd
  ...

	var rd := RenderingServer.get_rendering_device()

	if not rd.texture_is_valid(composite_texture.texture_rid):
		return

	var x_group := (texture_size.x * 2 - 1) / 8 + 1
	var y_group := (texture_size.y * 2 - 1) / 8 + 1

	# === ラスタライズ ===

	rd.texture_clear(_rasterize_texture_rids[0], Color(0, 0, 0, 0), 0, 1, 0, 1)
	rd.texture_clear(_rasterize_texture_rids[1], Color(0, 0, 0, 0), 0, 1, 0, 1)
	var framebuffer_index := 0

	var clear_color_values := PackedColorArray([Color(0, 0, 0, 0)])
	var draw_list := rd.draw_list_begin(
		_rasterize_framebuffers[framebuffer_index],
		RenderingDevice.INITIAL_ACTION_KEEP,
		RenderingDevice.FINAL_ACTION_READ,
		RenderingDevice.INITIAL_ACTION_KEEP,
		RenderingDevice.FINAL_ACTION_READ,
		clear_color_values)
	rd.draw_list_bind_render_pipeline(draw_list, _rasterize_render_pipeline)

	for path in path_layer.paths:
		if not path.visible:
			continue
		if path.indices.size() <= 2:
			continue

		# backbufferとの交換
		if path.boolean == Path.Boolean.Intersect or path.boolean == Path.Boolean.Xor:
			rd.draw_list_end()
			framebuffer_index = 1 - framebuffer_index
			rd.texture_copy(
				_rasterize_texture_rids[1 - framebuffer_index],
				_rasterize_texture_rids[framebuffer_index],
				Vector3(0, 0, 0),
				Vector3(0, 0, 0),
				Vector3(texture_size.x * 2, texture_size.y * 2, 0),
				0, 0, 0, 0)
			draw_list = rd.draw_list_begin(
				_rasterize_framebuffers[framebuffer_index],
				RenderingDevice.INITIAL_ACTION_KEEP,
				RenderingDevice.FINAL_ACTION_READ,
				RenderingDevice.INITIAL_ACTION_KEEP,
				RenderingDevice.FINAL_ACTION_READ,
				clear_color_values)
			rd.draw_list_bind_render_pipeline(draw_list, _rasterize_render_pipeline)

		# index bufferの作成
		var index_bytes := path.indices.to_byte_array()
		var index_buffer := rd.index_buffer_create(path.indices.size(), RenderingDevice.INDEX_BUFFER_FORMAT_UINT32, index_bytes)
		var index_array := rd.index_array_create(index_buffer, 0, path.indices.size())

		# vertex bufferの作成
		var vertex_bytes := path.vertices.to_byte_array()
		var vertex_buffers := [rd.vertex_buffer_create(vertex_bytes.size(), vertex_bytes)]
		var vertex_array := rd.vertex_array_create(path.vertices.size(), _rasterize_vertex_format, vertex_buffers)

		# framebufferのuniformのバインド
		var framebuffer_texture_uniform := RDUniform.new()
		framebuffer_texture_uniform.uniform_type = RenderingDevice.UNIFORM_TYPE_SAMPLER_WITH_TEXTURE
		framebuffer_texture_uniform.binding = 0
		framebuffer_texture_uniform.add_id(_sampler)
		framebuffer_texture_uniform.add_id(_rasterize_texture_rids[1 - framebuffer_index])
		var framebuffer_texture_set := rd.uniform_set_create([framebuffer_texture_uniform], _rasterize_render_shader, 0)

		# push_counstantsを詰める
		var rasterize_push_constant := PackedInt32Array()
		rasterize_push_constant.push_back(int(Main.document_size.x))
		rasterize_push_constant.push_back(int(Main.document_size.y))
		if path.boolean == Path.Boolean.Union:
			rasterize_push_constant.push_back(0)
		elif path.boolean == Path.Boolean.Diff:
			rasterize_push_constant.push_back(1)
		elif path.boolean == Path.Boolean.Intersect:
			rasterize_push_constant.push_back(2)
		elif path.boolean == Path.Boolean.Xor:
			rasterize_push_constant.push_back(3)
		rasterize_push_constant.push_back(0) # dummy
		var rasterize_push_constant_bytes := rasterize_push_constant.to_byte_array()

		rd.draw_list_bind_index_array(draw_list, index_array)
		rd.draw_list_bind_vertex_array(draw_list, vertex_array)
		rd.draw_list_bind_uniform_set(draw_list, framebuffer_texture_set, 0)
		rd.draw_list_set_push_constant(draw_list, rasterize_push_constant_bytes, rasterize_push_constant_bytes.size())
		rd.draw_list_draw(draw_list, true, 1)

		# intersectの2つ目のパス
		if path.boolean == Path.Boolean.Intersect:
			rd.draw_list_end()
			framebuffer_index = 1 - framebuffer_index
			rd.texture_copy(
				_rasterize_texture_rids[1 - framebuffer_index],
				_rasterize_texture_rids[framebuffer_index],
				Vector3(0, 0, 0),
				Vector3(0, 0, 0),
				Vector3(texture_size.x * 2, texture_size.y * 2, 0),
				0, 0, 0, 0)
			draw_list = rd.draw_list_begin(
				_rasterize_framebuffers[framebuffer_index],
				RenderingDevice.INITIAL_ACTION_KEEP,
				RenderingDevice.FINAL_ACTION_READ,
				RenderingDevice.INITIAL_ACTION_KEEP,
				RenderingDevice.FINAL_ACTION_READ,
				clear_color_values)
			rd.draw_list_bind_render_pipeline(draw_list, _rasterize_render_pipeline)

			# vertex bufferの作成
			var rect_vertices := PackedVector2Array([
				Vector2(0, 0),
				Vector2(texture_size.x * 2, 0),
				Vector2(texture_size.x * 2, texture_size.y * 2),
				Vector2(0, 0),
				Vector2(texture_size.x * 2, texture_size.y * 2),
				Vector2(0, texture_size.y * 2),
			])
			var rect_vertex_bytes := rect_vertices.to_byte_array()
			var rect_vertex_buffers := [rd.vertex_buffer_create(rect_vertex_bytes.size(), rect_vertex_bytes)]
			var rect_vertex_array := rd.vertex_array_create(6, _rasterize_vertex_format, rect_vertex_buffers)

			# framebufferのuniformのバインド
			var rect_framebuffer_texture_uniform := RDUniform.new()
			rect_framebuffer_texture_uniform.uniform_type = RenderingDevice.UNIFORM_TYPE_SAMPLER_WITH_TEXTURE
			rect_framebuffer_texture_uniform.binding = 0
			rect_framebuffer_texture_uniform.add_id(_sampler)
			rect_framebuffer_texture_uniform.add_id(_rasterize_texture_rids[1 - framebuffer_index])
			var rect_framebuffer_texture_set := rd.uniform_set_create([rect_framebuffer_texture_uniform], _rasterize_render_shader, 0)

			# push_counstantsを詰める
			var rect_rasterize_push_constant := PackedInt32Array()
			rect_rasterize_push_constant.push_back(texture_size.x * 2)
			rect_rasterize_push_constant.push_back(texture_size.y * 2)
			rect_rasterize_push_constant.push_back(5)
			rect_rasterize_push_constant.push_back(0) # dummy
			var rect_rasterize_push_constant_bytes := rect_rasterize_push_constant.to_byte_array()

			rd.draw_list_bind_vertex_array(draw_list, rect_vertex_array)
			rd.draw_list_bind_uniform_set(draw_list, rect_framebuffer_texture_set, 0)
			rd.draw_list_set_push_constant(draw_list, rect_rasterize_push_constant_bytes, rect_rasterize_push_constant_bytes.size())
			rd.draw_list_draw(draw_list, false, 1)

	rd.draw_list_end()

  ...

back bufferの交換やIntersect用の2パス目があるのでちょっと見通しが悪く長いですが、やっていることとしてはrd.draw_list_***でdraw_listといういわゆるコマンドバッファ的なものに対してindex buffer、vertex bufferをバインドして、uniformをバインドして、push_constantsを詰めて、描画するというコマンドを積んでいます。

パスの描画の際にパスのbooleanを解決しています。Aquamarine Painter ではパスのブーリアンをパスレイヤーに対して設定できます。

screenshot

以下のようなshaderを利用して、テクスチャのr成分とg成分に0や1を入れて、テクスチャを読んでboolean合成をして書き出すことでパスのブーリアンを実現しています。

rasterize.glsl
void main()
{
  vec2 rg = texture(backBufferImage, gl_FragCoord.xy / vec2(pushConstants.width, pushConstants.height)).rg;

  if (pushConstants.booleanMode == 0) {
    // Add
    out_color = vec4(1, 0, 0, 0);
  } else if (pushConstants.booleanMode == 1) {
    // Diff
    out_color = vec4(0, 0, 0, 0);
  } else if (pushConstants.booleanMode == 2) {
    // Intersect first pass
    out_color = vec4(rg.r, 1, 0, 0);
  } else if (pushConstants.booleanMode == 3) {
    // Xor
    out_color = vec4(1 - rg.r, 0, 0, 0);
  } else {
    // Intersect second pass
    out_color = vec4(rg.r* rg.g, 0, 0, 0);
  }
}

Intersectだけは描画した三角形の外部も0に戻す必要があるため、1パスめでg成分に新しいパスを書き込んで、フルスクリーンの2パス目でr成分とg成分を掛け合わせて出力しています。

ブーリアンを描画時にshaderで実装しているのは、リアルタイムでブーリアンが評価される必要があったためです。Aquamarine Painterの特性として、よくあるベクター系のツールのようにパスとパスを選択してbooleanツールを選んだときにbooleanが実行されれば良いというものではありません。一つのレイヤーに複数のパスを入れて、それのboolean合成が全部非破壊で生きた状況なツールとなっています。booleanで毎回pathのbooleanをちゃんと計算していてはリアルタイムでの操作が難しくなってしまいました。そのためパス自体のboolean計算は行わず、描画時にshaderでかいけつをすることにしています。

上記方法で問題になるのが輪郭線です。ブーリアンした後のパスの点列が手に入らないため、その点列をそのままラインとして描画するみたいな形の線の描画は出来ません。そこで描画したパスに対してポスプロ的に輪郭線を描画しています。


Godotの4.3に切り替えたことでテクスチャの間のバリアを手動で入れなくても勝手にリソースの利用状況からバリアを挟んでくれるようになったようで、コードがシンプルになり助かりました。

https://godotengine.org/article/rendering-acyclic-graph/

パスの輪郭線の描画

輪郭線はCompute ShaderでJump Flood Algorithmベースで描画しています。Jump Flood Algorithmでアウトラインを描画するのには、次の記事を参考にしました。

https://bgolus.medium.com/the-quest-for-very-wide-outlines-ba82ed442cd9

Jump Flood Algorithmを利用するとアウトラインの太さのpx数に対してlog2の回数のパスで描画が出来ます。

輪郭線描画はsobelとかいろいろなフィルタがありますが、より太い20pxとか40pxとかの太さのラインを描画するにはそのようなカーネルベースのフィルタでは難しいと思われます。しかしイラスト用途ではデカいキャンバスに太い線を描画することもあります。常に1pxの線を描画すれば良いというわけではありません。そこでパスからの距離をJFAで求めてライン描画しています。

レイヤー合成

上述したパスのブーリアンは01の値をテクスチャに書き込んでいます。ここで作成したマスクに実際に色を与えて合成をするのは別のCompute Shaderで行っています。

上記合成のコンピュートシェーダーを起動しているコードの抜粋を次に示します。

group_paint_compositor.gd
...

func _render_process(output_texture: PaintCompositor.TextureHandle, texture_rids: Array[RID], blend_modes: Array[PaintLayer.BlendMode], clippings: Array[bool], alphas: Array[int]) -> void:
	var rd := RenderingServer.get_rendering_device()

	if not rd.texture_is_valid(output_texture.texture_rid):
		return
	for rid in texture_rids:
		if not rd.texture_is_valid(rid):
			return

	var x_group := (texture_size.x * 1 - 1) / 8 + 1
	var y_group := (texture_size.y * 1 - 1) / 8 + 1

	var texture_uniform := RDUniform.new()
	texture_uniform.uniform_type = RenderingDevice.UNIFORM_TYPE_IMAGE
	texture_uniform.binding = 0
	texture_uniform.add_id(output_texture.texture_rid)
	var texture_set := rd.uniform_set_create([texture_uniform], _shader, 0)
	rd.texture_clear(output_texture.texture_rid, Color(0, 0, 0, 0), 0, 1, 0, 1)

	for index in texture_rids.size():
		# 合成のforeground画像をbindする
		var foreground := RDUniform.new()
		foreground.uniform_type = RenderingDevice.UNIFORM_TYPE_IMAGE
		foreground.binding = 0
		foreground.add_id(texture_rids[index])
		var foreground_set := rd.uniform_set_create([foreground], _shader, 1)

		# 合成のclippingの親に当たる画像を探してbindする
		var clipping_index := -1
		if clippings[index]:
			for i in index:
				if not clippings[index - i - 1]:
					clipping_index = index - i - 1
					break
		var clipping := RDUniform.new()
		clipping.uniform_type = RenderingDevice.UNIFORM_TYPE_IMAGE
		clipping.binding = 0
		if clipping_index == -1:
			clipping.add_id(output_texture.texture_rid)
		else:
			clipping.add_id(texture_rids[clipping_index])
		var clipping_set := rd.uniform_set_create([clipping], _shader, 2)

		# クリッピングのアルファ値も計算する
		var clipping_alpha := 100
		if clipping_index != -1:
			clipping_alpha = alphas[clipping_index]

		# push_counstantsを詰める
		var push_constant := PackedInt32Array()
		push_constant.push_back(blend_modes[index])
		push_constant.push_back(clippings[index])
		push_constant.push_back(texture_size.x * 1)
		push_constant.push_back(texture_size.y * 1)
		push_constant.push_back(alphas[index])
		push_constant.push_back(clipping_alpha)
		push_constant.push_back(0)
		push_constant.push_back(0) # dummy

		# コマンドを発行
		var compute_list := rd.compute_list_begin()
		rd.compute_list_bind_compute_pipeline(compute_list, _pipeline)
		rd.compute_list_bind_uniform_set(compute_list, texture_set, 0)
		rd.compute_list_bind_uniform_set(compute_list, foreground_set, 1)
		rd.compute_list_bind_uniform_set(compute_list, clipping_set, 2)
		rd.compute_list_set_push_constant(compute_list, push_constant.to_byte_array(), push_constant.size() * 4)
		rd.compute_list_dispatch(compute_list, x_group, y_group, 1)
		rd.compute_list_end()

...

レイヤーフォルダーのグループに含まれるレイヤーのテクスチャのリストを元に順番に合成を重ねていきます。その過程で下のレイヤーでクリッピングが有効になっている場合、クリッピングが有効になっているレイヤーを辿っていって、クリッピング元になるレイヤーを見つけるような処理をしています。クリッピング元のレイヤーがアルファを持っている場合はそのアルファも利用します。

下記は下のレイヤーでクリッピングのためのclippingのテクスチャの値とbaseとforegroundをもとに合成するglslです。

group_composite.glsl
#[compute]
#version 460

layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;

layout(rgba8, set = 0, binding = 0) uniform image2D base_image;
layout(rgba8, set = 1, binding = 0) uniform readonly image2D foreground_image;
layout(rgba8, set = 2, binding = 0) uniform readonly image2D clipping_image;

layout(push_constant, std430) uniform PushConstants {
    int blendMode;
    int clipping;
    int width;
    int height;
    int alpha;
    int clippingAlpha;
    int mirror;
} pushConstants;

void main() {
  ivec2 size = ivec2(pushConstants.width, pushConstants.height);
  ivec2 uv = ivec2(gl_GlobalInvocationID.xy);

  if (uv.x >= size.x || uv.y >= size.y) {
    return;
  }

  vec4 base = imageLoad(base_image, uv);

  if (pushConstants.mirror == 1) {
    uv.x = size.x - uv.x - 1;
  }

  vec4 foreground = imageLoad(foreground_image, uv);
  vec4 clipping = imageLoad(clipping_image, uv);

  foreground.a *= float(pushConstants.alpha) / 100.0;
  clipping.a *= float(pushConstants.clippingAlpha) / 100.0;
  if (pushConstants.clipping == 1) {
    foreground.a *= clipping.a;
  }

  // Porter Dugg, Source Over
  float f_b = 1 - foreground.a;
  float f_f = 1;


  vec4 color;
  color.a = base.a * f_b + foreground.a * f_f;

  vec3 blendColor;

  switch (pushConstants.blendMode) {
    case 0:  // Normal
      blendColor = foreground.rgb;
      break;
    case 1:  // Add
      blendColor =min(vec3(1), base.rgb + foreground.rgb);
      break;
    case 2:  // Multiply
      blendColor = base.rgb * foreground.rgb;
      break;
    case 3:  // Screen
      blendColor = 1 - (1 - base.rgb) * (1 - foreground.rgb);
      break;
    case 4:  // Overlay
      if (base.a < 0.5) {
        blendColor = 2 * base.rgb * foreground.rgb;
      } else {
        blendColor = 1 - 2 * (1 - base.rgb) * (1 - foreground.rgb);
      }
      break;
  }

  vec3 color_prime = blendColor * base.a + foreground.rgb * (1 - base.a);
  color.rgb = (base.rgb * f_b * base.a + color_prime * f_f * foreground.a) / color.a;

  if (pushConstants.clipping == 0) {
    if (color.a == 0.0) {
      color.rgb = vec3(0.0);
    }
  } else {
    if (color.a == 0.0) {
      return;
    }
  }


  if (pushConstants.mirror == 1) {
    uv.x = size.x - uv.x - 1;
  }

  imageStore(base_image, uv, color);
}

上記フォルダーレイヤーの合成は、フォルダー内のいずれかのレイヤーがdirtyになったときに実行されます。フォルダー内のレイヤーに変更がない場合は合成をスキップして合成済みのテクスチャを次の合成に回します。

ミップマップの生成

絵を書いている最中に場合によってはキャンバスを小さく縮小したりします。

screenshot

そのさいにmipmapが存在しないとガビガビになってしまうので、一番ルートのコンポジット時にはmipmapを生成しています。

root_paint_compositor.gd
...

	# mipmapの生成
	var mipmap_in_texture_sets: Array[RID] = []
	var mipmap_out_texture_sets: Array[RID] = []
	for i in output_texture.mip_size - 1:
		var in_uniform := RDUniform.new()
		in_uniform.uniform_type = RenderingDevice.UNIFORM_TYPE_SAMPLER_WITH_TEXTURE
		in_uniform.binding = 0
		in_uniform.add_id(_sampler)
		in_uniform.add_id(output_texture.mipmap_texture_rids[i])
		mipmap_in_texture_sets.append(rd.uniform_set_create([in_uniform], _mipmap_shader, 1))
		var out_uniform := RDUniform.new()
		out_uniform.uniform_type = RenderingDevice.UNIFORM_TYPE_IMAGE
		out_uniform.binding = 0
		out_uniform.add_id(output_texture.mipmap_texture_rids[i + 1])
		mipmap_out_texture_sets.append(rd.uniform_set_create([out_uniform], _mipmap_shader, 0))

	for i in output_texture.mip_size - 1:
		var x_group_mipmap := (texture_size.x * 1 / (2 ** (i + 1)) - 1) / 8 + 1
		var y_group_mipmap := (texture_size.y * 1 / (2 ** (i + 1)) - 1) / 8 + 1

		# push_counstantsを詰める
		var push_constant := PackedInt32Array()
		push_constant.push_back(texture_size.x * 1 / (2 ** (i + 1)))
		push_constant.push_back(texture_size.y * 1 / (2 ** (i + 1)))
		push_constant.push_back(0) # dummy
		push_constant.push_back(0) # dummy

		# コマンドを発行
		var compute_list := rd.compute_list_begin()
		rd.compute_list_bind_compute_pipeline(compute_list, _mipmap_pipeline)
		rd.compute_list_bind_uniform_set(compute_list, mipmap_out_texture_sets[i], 0)
		rd.compute_list_bind_uniform_set(compute_list, mipmap_in_texture_sets[i], 1)
		rd.compute_list_set_push_constant(compute_list, push_constant.to_byte_array(), push_constant.size() * 4)
		rd.compute_list_dispatch(compute_list, x_group_mipmap, y_group_mipmap, 1)
		rd.compute_list_end()

...

アンチエイリアス

パスのブーリアンを行ったりJFAでアウトラインをポスプロで描画する都合で、パスの描画にはMSAAを利用していません。そのため若干ジャギっているのが気になる仕上がりになっていたため、パスの合成時にFXAAでアンチエイリアスを書けるようにしています。

screenshot

下記はパスのbooleanを行った結果に色を与えてレイヤーのテクスチャに書き出すglslです。下記とは別にパスとアウトラインを合成するためのglslも別にあります。どちらもFXAAの処理が入っています。

path_compositor.glsl
#[compute]
#version 460

layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;

layout(rgba8, set = 0, binding = 0) uniform image2D base_image;
layout(set = 1, binding = 0) uniform sampler2D viewport_image;
layout(set = 2, binding = 0) uniform sampler2D gradientTexture;

const float FIXED_THRESHOLD = 0.0833;
const float RELATIVE_THRESHOLD = 0.166;
const float SUBPIXEL_BLENDING = 0.75;
const float EDGE_STEP_SIZES_ARRAY[10] = float[](
  1.0, 1.0, 1.0, 1.0, 1.5, 2.0, 2.0, 2.0, 2.0, 4.0
);
const float LAST_EDGE_STEP_GUESS = 8.0;

layout(push_constant, std430) uniform PushConstants {
  int width;
  int height;
  int materialType;
  int r8;
  int g8;
  int b8;
  int a8;
  int padding1;
  vec2 pos0;
  vec2 pos1;
  vec2 pos2;
  int padding2[2];
} pushConstants;

float getMaskOffset(vec2 uv, float uOffset, float vOffset) {
  uv += vec2(uOffset + 0.5, vOffset + 0.5) / vec2(pushConstants.width, pushConstants.height);
  return textureLod(viewport_image, uv, 0.0).r;
}

struct MaskNeighbor {
  float center;
  float topLeft;
  float top;
  float topRight;
  float left;
  float right;
  float bottomLeft;
  float bottom;
  float bottomRight;
  float highest;
  float lowest;
  float range;
};

MaskNeighbor getMaskNeighbor(vec2 uv) {
  MaskNeighbor neighbor;
  neighbor.center = getMaskOffset(uv, 0.0, 0.0);
  neighbor.topLeft = getMaskOffset(uv, -1.0, -1.0);
  neighbor.top = getMaskOffset(uv, 0.0, -1.0);
  neighbor.topRight = getMaskOffset(uv, 1.0, -1.0);
  neighbor.left = getMaskOffset(uv, -1.0, 0.0);
  neighbor.right = getMaskOffset(uv, 1.0, 0.0);
  neighbor.bottomLeft = getMaskOffset(uv, -1.0, 1.0);
  neighbor.bottom = getMaskOffset(uv, 0.0, 1.0);
  neighbor.bottomRight = getMaskOffset(uv, 1.0, 1.0);
  neighbor.highest = max(max(max(max(neighbor.center, neighbor.top), neighbor.left), neighbor.right), neighbor.bottom);
  neighbor.lowest = min(min(min(min(neighbor.center, neighbor.top), neighbor.left), neighbor.right), neighbor.bottom);
  neighbor.range = neighbor.highest - neighbor.lowest;
  return neighbor;
}

bool isHorizontal(MaskNeighbor neighbor) {
  float horizontal = 2.0 * abs(neighbor.top + neighbor.bottom - 2.0 * neighbor.center) + abs(neighbor.topLeft+ neighbor.bottomLeft - 2.0 * neighbor.left) + abs(neighbor.topRight + neighbor.bottomRight - 2.0 * neighbor.right);
  float vertical = 2.0 * abs(neighbor.left + neighbor.right - 2.0 * neighbor.center) + abs(neighbor.topLeft + neighbor.topRight - 2.0 * neighbor.top) + abs(neighbor.bottomLeft + neighbor.bottomRight - 2.0 * neighbor.bottom);
  return horizontal >= vertical;
}

struct FxaaEdge {
  bool isHorizontal;
  float pixelStep;
  float maskGradient;
  float otherMask;
};

FxaaEdge getFxaaEdge(MaskNeighbor neighbor) {
  FxaaEdge edge;
  edge.isHorizontal = isHorizontal(neighbor);
  float maskP, maskN;
  if (edge.isHorizontal) {
    edge.pixelStep = 1.0 / float(pushConstants.height);
    maskP = neighbor.top;
    maskN = neighbor.bottom;
  } else {
    edge.pixelStep = 1.0 / float(pushConstants.width);
    maskP = neighbor.left;
    maskN = neighbor.right;
  }
  float gradientP = abs(maskP - neighbor.center);
  float gradientN = abs(maskN - neighbor.center);
  if (gradientP < gradientN) {
    edge.pixelStep = -edge.pixelStep;
    edge.maskGradient = gradientN;
    edge.otherMask = maskN;
  } else {
    edge.maskGradient = gradientP;
    edge.otherMask = maskP;
  }
  return edge;
}

bool canSkipFxaa(MaskNeighbor neighbor) {
  return neighbor.range < max(FIXED_THRESHOLD, RELATIVE_THRESHOLD * neighbor.highest);
}

float getSubpixelBlendFactor(MaskNeighbor neighbor) {
  float filterValue = 2.0 * (neighbor.top + neighbor.bottom + neighbor.left + neighbor.right);
  filterValue += neighbor.topLeft + neighbor.topRight + neighbor.bottomLeft + neighbor.bottomRight;
  filterValue *= 1.0 / 12.0;
  filterValue = abs(filterValue - neighbor.center);
  filterValue = clamp(filterValue / neighbor.range, 0.0, 1.0);
  filterValue = smoothstep(0.0, 1.0, filterValue);
  return filterValue * filterValue * SUBPIXEL_BLENDING;
}

float getEdgeBlendFactor(MaskNeighbor neighbor, FxaaEdge edge, vec2 uv) {
  vec2 edgeUV = uv;
  vec2 uvStep = vec2(0.0);
  if (edge.isHorizontal) {
    uvStep.y += 0.5 * edge.pixelStep;
    uvStep.x = 1.0 / float(pushConstants.width);
  } else {
    uvStep.x += 0.5 * edge.pixelStep;
    uvStep.y = 1.0 / float(pushConstants.height);
  }

  float edgeMask = 0.5 * (neighbor.center + edge.otherMask);
  float gradientThreshold = 0.25 * edge.maskGradient;

  vec2 uvP = edgeUV + uvStep;
  float maskDeltaP = getMaskOffset(uvP, 0.0, 0.0) - edgeMask;
  bool atEndP = abs(maskDeltaP) >= gradientThreshold;

  int i;
  for (i = 0; i < 10 && !atEndP; i++) {
    uvP += uvStep * EDGE_STEP_SIZES_ARRAY[i];
    maskDeltaP = getMaskOffset(uvP, 0.0, 0.0) - edgeMask;
    atEndP = abs(maskDeltaP) >= gradientThreshold;
  }
  if (!atEndP) {
    uvP = edgeUV + uvStep * LAST_EDGE_STEP_GUESS;
  }

  vec2 uvN = edgeUV - uvStep;
  float maskDeltaN = getMaskOffset(uvN, 0.0, 0.0) - edgeMask;
  bool atEndN = abs(maskDeltaN) >= gradientThreshold;

  for (i = 0; i < 10 && !atEndN; i++) {
    uvN -= uvStep * EDGE_STEP_SIZES_ARRAY[i];
    maskDeltaN = getMaskOffset(uvN, 0.0, 0.0) - edgeMask;
    atEndN = abs(maskDeltaN) >= gradientThreshold;
  }
  if (!atEndN) {
    uvN = edgeUV - uvStep * LAST_EDGE_STEP_GUESS;
  }

  float distanceToEndP, distanceToEndN;
  if (edge.isHorizontal) {
    distanceToEndP = uvP.x - uv.x;
    distanceToEndN = uv.x - uvN.x;
  } else {
    distanceToEndP = uvP.y - uv.y;
    distanceToEndN = uv.y - uvN.y;
  }

  float distanceToNearestEnd;
  bool deltaSign;
  if (distanceToEndP <= distanceToEndN) {
    distanceToNearestEnd = distanceToEndP;
    deltaSign = maskDeltaP >= 0;
  } else {
    distanceToNearestEnd = distanceToEndN;
    deltaSign = maskDeltaN >= 0;
  }

  if (deltaSign == (neighbor.center - edgeMask >= 0)) {
    return 0.0;
  } else {
    return 0.5 - distanceToNearestEnd / (distanceToEndP + distanceToEndN);
  }
}

float getMask(vec2 uv) {
  MaskNeighbor neighbor = getMaskNeighbor(uv);
  if (canSkipFxaa(neighbor)) {
    return neighbor.center;
  }

  FxaaEdge edge = getFxaaEdge(neighbor);
  float blendFactor = max(getSubpixelBlendFactor(neighbor), getEdgeBlendFactor(neighbor, edge, uv));
  if (edge.isHorizontal) {
    return getMaskOffset(uv, 0.0, blendFactor);
  } else {
    return getMaskOffset(uv, blendFactor, 0.0);
  }
}

vec4 getColor() {
  vec4 color;
  if (pushConstants.materialType == 0) {
    color.r = float(pushConstants.r8) / 255.0;
    color.g = float(pushConstants.g8) / 255.0;
    color.b = float(pushConstants.b8) / 255.0;
    color.a = float(pushConstants.a8) / 255.0;
  } else if (pushConstants.materialType == 1) {
    vec2 v = (pushConstants.pos1 - pushConstants.pos0);
    vec2 uv = vec2(gl_GlobalInvocationID.xy) / 2.0;
    float t = dot(uv - pushConstants.pos0, v) / dot(v, v);
    color = texture(gradientTexture, vec2(t, 0.5));
  } else if (pushConstants.materialType == 2) {
    vec2 a = pushConstants.pos1 - pushConstants.pos0;
    vec2 b = pushConstants.pos2 - pushConstants.pos0;
    float aLength = length(a);
    float bLength = length(b);
    vec2 aNorm = a / aLength;
    vec2 bNorm = b / bLength;
    if (aLength < 0.0001 || bLength < 0.0001) {
      color = texture(gradientTexture, vec2(1.0, 0.5));
      color = vec4(1.0, 0.0, 1.0, 1.0);
    } else {
      vec2 uv = vec2(gl_GlobalInvocationID.xy) / 2.0;
      vec2 p = uv - pushConstants.pos0;
      vec2 pp = dot(p, aNorm) * aNorm + dot(p, bNorm) * bNorm / bLength * aLength;
      float t = length(pp) / aLength;
      color = texture(gradientTexture, vec2(t, 0.5));
    }
  } else if (pushConstants.materialType == 3) {
    vec2 checker_count = vec2(pushConstants.width, pushConstants.height) / 96.0;
    vec2 uv = vec2(gl_GlobalInvocationID.xy) / vec2(pushConstants.width, pushConstants.height);
    float checker = mod(dot(vec2(1.0), step(vec2(0.5), fract(uv * checker_count))), 2.0);
    color.rgb = mix(vec3(1.0, 0.0, 1.0), vec3(0.0, 1.0, 1.0), checker);
    color.a = 1.0;
  }
  return color;
}

void main() {
  ivec2 size = ivec2(pushConstants.width, pushConstants.height);
  ivec2 iuv = ivec2(gl_GlobalInvocationID.xy);

  if (iuv.x >= size.x || iuv.y >= size.y) {
    return;
  }

  vec2 uv = (vec2(iuv) + vec2(0.5)) / vec2(size);

  vec4 color = getColor();

  float mask = getMask(uv);
  color.a *= mask;

  imageStore(base_image, iuv, color);
}

Godotで実装した感想

今回は一部をRustのGDExtensionで実装しましたが、残りの大半はGDScriptで実装しました。GDScriptはサクッと動くものが作れて、そういう用途には強いという感想です。また、GodotのGUIはデフォルトでいろいろなものが入っているので、それを使うとサクッとGUIを作れるのも良い感じでした。

一方で、Godotのシグナルとかを使って色々なクラスが依存したりするのは、サクッと動くものを作るのには便利ですが、何も考えずに使うと結構設計が大変なことになりそうな印象を受けました。今回は取りあえず動くツールを作ってみるのを優先したので、設計が結構乱雑で後からコードを触って機能追加とかはあまりしたくない感じになっています。

GDScriptもシグナルの仕組みも、サクッと動くものを作るには良いが、大規模なものを作っていくにはちょっとスクリプト言語っぽすぎるかなあというのが個人的な感想でした。GodotはC#対応もあったりするので、C#を使ってインタフェースとか切って丁寧に設計をしてみるというのが、大規模の開発では良かったりするのかもしれません?

Godotでグラフィクス周りを触って自分でパイプラインを作ってGPUに投げるコマンドを積むのはとてもやりやすく感じました。Vulkan的な構造体を用意してコマンドを記録するタイプで現代的で、それでいてVulkanみたいな面倒さがだいぶ排除されているように感じます。Godot 4.3からの非巡回有向グラフを利用したリソースの追跡は手動でバリアをいれる手間も省けて実装が用意になりました。

今後の課題

今回作ったツールは取りあえず動くお絵かきツールを一個雑に作ることを目標に作っており、コードも乱雑であまり今後アップデートをすることは考えていませんが、今回やり残していて今後また別のお絵かきツールを作るときには実装してみたい機能があります。

ペンの描画結果をシンプル化したベクターにする機能

Aquamarine Painterでは現状ではポチポチとクリックすることでコントロールポイントを配置して絵を作っていきます。でも、せっかくならペンタブで絵をスイスイ描きたいですよね。しかし、ペンタブで描いた線をそのままベクターとするだけでは編集しやすさとかがいまいちです。そこで、ペンタブで線を描いたときの点列をシンプルなスプライン曲線にする機能を実装してみたいです。

点列からベジェにフィッティングするアルゴリズムははGPU Gems 1に「AN ALGORITHM FOR AUTOMATICALLY FITTING DIGITIZED GURVES」という章で載っているものがあったりします。もととなった論文は以下のもののようです。

https://dl.acm.org/doi/10.5555/90767.90941

Kritaというツールにも似たようなものが搭載されていて、フリーハンドパスツールで描くとそれが扱いやすいベジェになってくれるので、そういう機能を入れてみたいです。

screenshot
screenshot

また、Clip Studio Paintのベクターレイヤーだとデフォルトでもう少し点が多いベクターになるようですが、追加でベクター線を「ベクター線単純化」を利用してシンプルなベクター線にできるようです。

screenshot
https://help.clip-studio.com/ja-jp/manual_jp/810_subtools/さ行.htm#XREF_94564__線編集7

PPW Curvesはベジェ曲線ではありません。ベジェ曲線ではないスプライン曲線へのフィッティングとなると、なにか新しくアルゴリズムを作る必要があるかもしれません。

ラスター画像も取り込めるようにする

Aquamarine Painterは現状では完全にベクターの線しか描画できません。しかし、絵の下絵であったり、テクスチャに利用したりというのでラスター画像を使えると便利そうです。今回は実装に至りませんでしたが、実用的なお絵かきツールにするのであればぜひ実装したい機能です。

ベジェにも対応し、svgをインポート・エクスポートできるようにする

現状はベクターの一般的なファイルフォーマットであるSVGもインポート・エクスポートできません。これはSVGがベジェベースであり、このツールではベジェ曲線を対応していないというのが理由です。

インポート

ベジェ曲線に一切対応せずPPW Curvesにのみ対応しているというのはだいぶ尖り過ぎな気がするので、ベジェに限らずいくつかのパラメトリック曲線を使えるようにしてみても良いかもしれません。そうすればSVGのパスデータをそのままインポートできるようになるはずです。

エクスポート

今回利用したPPW Curvesはベジェ曲線とは異なる曲線になるので、残念ながらそのままSVGとしては書き出せません。PPW Curvesを複数のベジェ曲線の連結で近似して誤差評価などもしつつベジェ曲線として表現してSVGとして書き出すような機能を作ってみるのも面白そうです。

例えば有理ベジェ曲線をベジェ曲線の列で近似するような技術や3次ベジェ曲線を2次のベジェ曲線の列で近似するような技術は既存の論文があったりするので、このあたりを参考にして特殊な曲線をベジェ曲線で近似する方法を考えてみるのも良いかもしれません。

https://www.sciencedirect.com/science/article/abs/pii/S0167839608000174?via%3Dihub

https://ieeexplore.ieee.org/document/6298235

https://dl.acm.org/doi/10.1145/3406178

より効率的な描画の実装

非常にスペックの低いノートPCでデモに使っているイラストを開いてみると若干コントロールポイントを動かしたときにカクつく感じがありました。レイヤー数が増えるほど合成負荷も上がるような実装になっているので、よりレイヤー数の多いイラストを描くには、もう少し最適化をしたいかもしれません。

また、今回はだいぶ富豪的にGPUメモリを使っている気がしていて、もう少しなんとかならないかという気がしないでもないです。ちゃんと計測していないのでメモリ使用量を計測するところからではありますが、現状の実装だとイラストのフル解像度のテクスチャがレイヤーの数だけ全部GPUメモリに乗っています。レイヤーを大量に使えば使うほどメモリ使用量が大きくなっているので、もう少しテクスチャのGPUメモリを開放するようにしてみたほうが良いかもしれません。フォルダに入っているレイヤーはフォルダの内部が変更されない限り合成語のレイヤーだけを保持しておけば良いかもしれません。そういう合成したテクスチャを開放するなどをして、GPUメモリの消費量を減らすことも考えてみたほうが良いかもしれません。

より実用的なお絵かきツールを作るには、あまり強くないPCでもサクサク動いてほしいものです。

おわりに

今回はお試しでサクッとお絵かきツールを作るつもりで一つ作ってみました。
実際に制作期間は趣味の時間の大半をぶち込んで2ヶ月ちょっとかかっていますが良い経験になった気がします。

screenshot
Aquamarine Painterで描いたイラスト

みんなもお絵かきツールを自作してみよう!

Discussion

ゴリラライオンゴリラライオン

かなり本格的なお絵描きツールになっていて、すごいですね。

私もお絵描きツール作るのに興味あったので、コードを勉強させてもらいます…!!