From f308f988969cd3bebf4c605cc4e9c6714f27408d Mon Sep 17 00:00:00 2001 From: Ebise Lutica <7106976+EbiseLutica@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:45:39 +0900 Subject: [PATCH 01/16] =?UTF-8?q?feat:=20=E3=82=A8=E3=83=B3=E3=83=88?= =?UTF-8?q?=E3=83=AA=E3=82=A2=E3=82=BB=E3=83=B3=E3=83=96=E3=83=AA=E4=BB=A5?= =?UTF-8?q?=E5=A4=96=E3=81=AE=E3=82=B7=E3=83=BC=E3=83=B3=E3=82=92=E7=99=BB?= =?UTF-8?q?=E9=8C=B2=E3=81=99=E3=82=8B=20UseScenesFrom=20=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit これまで Scene 派生クラスの自動登録は Assembly.GetEntryAssembly() のみを 走査していたため、ゲームをエンジン層とコンテンツ層の2プロジェクトに分割し、 シーンをエンジン側のアセンブリに置くと LoadScene が 「The scene "Xxx" is not registered.」で落ちていた。 PrometeAppBuilder に UseScenesFrom(Assembly) と UseScenesFrom() を足し、 エントリアセンブリに加えて任意のアセンブリを探索対象にできるようにした。 PrometeApp.Create() .UseScenesFrom(typeof(LoadingScene).Assembly) .BuildWithVulkanDesktop(opts); 既定の挙動は変えていないので、単一アセンブリの構成には影響しない。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019eZmjGkVajSwKumgLsMwrA --- Promete.Test/SceneRegistrationTests.cs | 106 +++++++++++++++++++++++++ Promete/PrometeApp.cs | 64 +++++++++++++-- 2 files changed, 162 insertions(+), 8 deletions(-) create mode 100644 Promete.Test/SceneRegistrationTests.cs diff --git a/Promete.Test/SceneRegistrationTests.cs b/Promete.Test/SceneRegistrationTests.cs new file mode 100644 index 0000000..ca3455c --- /dev/null +++ b/Promete.Test/SceneRegistrationTests.cs @@ -0,0 +1,106 @@ +using FluentAssertions; +using Promete.Headless; + +namespace Promete.Test; + +public class SceneRegistrationTests +{ + [Fact] + public void LoadScene_WithoutUseScenesFrom_ShouldThrow() + { + using var app = PrometeApp.Create().BuildWithHeadless(); + + // テストアセンブリはエントリアセンブリではないため、既定では登録されない + var act = () => app.LoadScene(); + + act.Should().Throw("エントリアセンブリ以外のシーンは既定では登録されないはず"); + } + + [Fact] + public void UseScenesFrom_ShouldRegisterScenesInSpecifiedAssembly() + { + using var app = PrometeApp + .Create() + .UseScenesFrom(typeof(ExternalAssemblyScene).Assembly) + .BuildWithHeadless(); + + var loaded = false; + app.Start += () => + { + app.LoadScene(); + loaded = ExternalAssemblyScene.HasStarted; + app.Exit(); + }; + + ExternalAssemblyScene.HasStarted = false; + app.Run(); + + loaded.Should().BeTrue("UseScenesFrom で指定したアセンブリのシーンは読み込めるはず"); + } + + [Fact] + public void UseScenesFromGeneric_ShouldRegisterScenesInAssemblyOfType() + { + using var app = PrometeApp + .Create() + .UseScenesFrom() + .BuildWithHeadless(); + + Exception? thrown = null; + app.Start += () => + { + try + { + app.LoadScene(); + } + catch (Exception e) + { + thrown = e; + } + + app.Exit(); + }; + + app.Run(); + + thrown.Should().BeNull("型指定のオーバーロードでも同じアセンブリが登録されるはず"); + } + + [Fact] + public void UseScenesFrom_WithSameAssemblyTwice_ShouldNotThrow() + { + var assembly = typeof(ExternalAssemblyScene).Assembly; + + var act = () => + { + using var app = PrometeApp + .Create() + .UseScenesFrom(assembly) + .UseScenesFrom(assembly) + .BuildWithHeadless(); + }; + + act.Should().NotThrow("同じアセンブリを重複して指定しても問題ないはず"); + } + + [Fact] + public void UseScenesFrom_WithNull_ShouldThrow() + { + var act = () => PrometeApp.Create().UseScenesFrom(null!); + + act.Should().Throw(); + } + + /// + /// エントリアセンブリの外側に置かれたシーンを模したもの。 + /// + private sealed class ExternalAssemblyScene : Scene + { + public static bool HasStarted { get; set; } + + public override void OnStart() + { + HasStarted = true; + } + } +} diff --git a/Promete/PrometeApp.cs b/Promete/PrometeApp.cs index 7b87138..ecabfbf 100644 --- a/Promete/PrometeApp.cs +++ b/Promete/PrometeApp.cs @@ -40,13 +40,17 @@ public sealed class PrometeApp : IDisposable private IScreenBlitter _screenBlitter; private int _statusCode; - private PrometeApp(ServiceCollection services, List pluginTypes) + private PrometeApp( + ServiceCollection services, + List pluginTypes, + List sceneAssemblies + ) { _mainThread = Thread.CurrentThread; _services = services; _pluginTypes = pluginTypes; - RegisterAllScenes(); + RegisterAllScenes(sceneAssemblies); services.AddSingleton(this); services.AddSingleton(); #pragma warning disable CS0618 // 型またはメンバーが旧型式です @@ -522,18 +526,34 @@ private void ProcessNextFrameQueue() } } - private void RegisterAllScenes() + private void RegisterAllScenes(List additionalAssemblies) { // DefaultScene を明示的に登録 _services.AddTransient(); - var asm = + var entryAsm = Assembly.GetEntryAssembly() ?? throw new InvalidOperationException("There is no entry assembly."); - // Scene 派生クラスを全て取得する - var types = asm.GetTypes(); - foreach (var type in types.Where(t => t.IsSubclassOf(typeof(Scene)))) + // エントリアセンブリに加え、UseScenesFrom で指定されたアセンブリも探索する + var assemblies = new List { entryAsm }; + foreach (var asm in additionalAssemblies.Where(asm => asm != entryAsm)) + { + assemblies.Add(asm); + } + + foreach (var asm in assemblies) + { + RegisterScenesIn(asm); + } + } + + /// + /// 指定したアセンブリの 派生クラスを DI に登録する。 + /// + private void RegisterScenesIn(Assembly assembly) + { + foreach (var type in assembly.GetTypes().Where(t => t.IsSubclassOf(typeof(Scene)))) { // IgnoredSceneAttribute が付与されている場合は無視する if (type.GetCustomAttribute() is not null) @@ -569,6 +589,7 @@ public sealed class PrometeAppBuilder ]; private readonly List _pluginTypes = []; + private readonly List _sceneAssemblies = []; private readonly ServiceCollection _services; internal PrometeAppBuilder() @@ -576,6 +597,33 @@ internal PrometeAppBuilder() _services = []; } + /// + /// 指定したアセンブリに含まれる 派生クラスを登録対象に追加します。 + /// + /// 既定ではエントリアセンブリのシーンだけが自動登録されます。 + /// ゲームをエンジン層とコンテンツ層に分割している場合など、 + /// エントリアセンブリ以外にシーンを置いているときに使用してください。 + /// + /// シーンを探索するアセンブリ。 + /// このビルダーインスタンス。 + public PrometeAppBuilder UseScenesFrom(Assembly assembly) + { + ArgumentNullException.ThrowIfNull(assembly); + if (!_sceneAssemblies.Contains(assembly)) + { + _sceneAssemblies.Add(assembly); + } + + return this; + } + + /// + /// 指定した型が属するアセンブリに含まれる 派生クラスを登録対象に追加します。 + /// + /// 登録したいアセンブリに含まれる任意の型。 + /// このビルダーインスタンス。 + public PrometeAppBuilder UseScenesFrom() => UseScenesFrom(typeof(T).Assembly); + /// /// 指定した型のプラグインを追加します。 /// @@ -607,7 +655,7 @@ public PrometeAppBuilder Use() public PrometeApp Build(WindowOptions? opts) where T : BackendBase, new() { - var app = new PrometeApp(_services, _pluginTypes); + var app = new PrometeApp(_services, _pluginTypes, _sceneAssemblies); app.RegisterBackend(new T(), opts ?? WindowOptions.Default); return app; } From c03619268b46b0ceff1e9cdea4ad17c047531628 Mon Sep 17 00:00:00 2001 From: Ebise Lutica <7106976+EbiseLutica@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:46:48 +0900 Subject: [PATCH 02/16] =?UTF-8?q?docs:=20=E3=82=B7=E3=83=BC=E3=83=B3?= =?UTF-8?q?=E3=81=AE=E7=99=BB=E9=8C=B2=E7=AF=84=E5=9B=B2=E3=81=A8=20UseSce?= =?UTF-8?q?nesFrom=20=E3=81=AE=E4=BD=BF=E3=81=84=E6=96=B9=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E8=A8=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scene 派生クラスの自動登録がエントリアセンブリに限られることは、 これまでドキュメントに書かれていなかった。エントリアセンブリ外に シーンを置いたときの対処とあわせて記載する。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019eZmjGkVajSwKumgLsMwrA --- docs-llm.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs-llm.md b/docs-llm.md index 6bfc7d1..cf76b95 100644 --- a/docs-llm.md +++ b/docs-llm.md @@ -245,6 +245,25 @@ App.PushScene(); // ポーズメニューを重ねる App.PopScene(); // ポーズメニューを閉じる ``` +#### シーンの登録 + +`Scene` 派生クラスは**エントリアセンブリのものだけが自動的に DI へ登録**されます。 +そのため通常は登録を意識する必要はありません。 + +エントリアセンブリの外にシーンを置いている場合(ゲームをエンジン層とコンテンツ層の +2プロジェクトに分割し、共通シーンをエンジン側に置いた場合など)は、`UseScenesFrom` で +探索対象のアセンブリを追加してください。指定しないと `LoadScene` が +`The scene "Xxx" is not registered.` で失敗します。 + +```csharp +var app = PrometeApp.Create() + .UseScenesFrom(typeof(LoadingScene).Assembly) // アセンブリを直接指定 + .UseScenesFrom() // 型からアセンブリを指定(同じ意味) + .BuildWithVulkanDesktop(opts); +``` + +登録から除外したいシーンには `[IgnoredScene]` を付けます。 + ### プラグインシステム PrometeはMicrosoft.Extensions.DependencyInjectionをベースとしたDIコンテナを採用しています。 From e69eb854c1940570854bf4afe7e70ff8d0c26671 Mon Sep 17 00:00:00 2001 From: Ebise Lutica <7106976+EbiseLutica@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:13:41 +0900 Subject: [PATCH 03/16] =?UTF-8?q?fix(Rendering):=20=E3=83=88=E3=83=AA?= =?UTF-8?q?=E3=83=A0=E5=BA=A7=E6=A8=99=E3=82=92=E5=B7=A6=E4=B8=8A=E5=8E=9F?= =?UTF-8?q?=E7=82=B9=E3=81=AB=E7=B5=B1=E4=B8=80=E3=81=97Y=E5=8F=8D?= =?UTF-8?q?=E8=BB=A2=E3=82=92GL=E3=83=A9=E3=83=B3=E3=83=8A=E3=83=BC?= =?UTF-8?q?=E3=81=B8=E7=A7=BB=E5=8B=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RenderCommandQueue.PushTrim がバックエンド非依存層で OpenGL 固有の 左下原点変換を行っていたため、キューは左上原点で座標を保持し、 Y反転は GLBeginTrim/GLEndTrimCommandRunner 側で行うよう変更。 Vulkan など左上原点のバックエンド追加への布石。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016A9ZxvQBn4vAu8Erah3rHr --- .../src/content/docs/guide/extends/rendercommand.md | 4 ++-- Promete/Graphics/Rendering/Commands/TrimCommands.cs | 2 +- .../Rendering/GL/Runners/GLBeginTrimCommandRunner.cs | 7 ++++++- .../Rendering/GL/Runners/GLEndTrimCommandRunner.cs | 7 ++++++- Promete/Graphics/Rendering/RenderCommandQueue.cs | 6 ++---- 5 files changed, 17 insertions(+), 9 deletions(-) diff --git a/Promete.Docs/src/content/docs/guide/extends/rendercommand.md b/Promete.Docs/src/content/docs/guide/extends/rendercommand.md index 29069bf..d3669a4 100644 --- a/Promete.Docs/src/content/docs/guide/extends/rendercommand.md +++ b/Promete.Docs/src/content/docs/guide/extends/rendercommand.md @@ -103,7 +103,7 @@ queue.Enqueue(new DrawPieTextureCommand `BeginTrimCommand` / `EndTrimCommand` で描画範囲を矩形に制限します。`Container` ノードの実装で使用されます。 ```csharp -// トリム開始(物理ピクセル座標で指定) +// トリム開始(物理ピクセル座標・左上原点で指定) queue.Enqueue(new BeginTrimCommand { X = physicalX, @@ -151,7 +151,7 @@ public override void Collect(RenderCommandQueue queue, RenderContext ctx) ```csharp public class MyCommandRunner : CommandRunner { - protected override void Execute(MyCommand command, RenderContext ctx) + public override void Execute(MyCommand command) { // コマンドの実行処理 } diff --git a/Promete/Graphics/Rendering/Commands/TrimCommands.cs b/Promete/Graphics/Rendering/Commands/TrimCommands.cs index 307399b..fac010f 100644 --- a/Promete/Graphics/Rendering/Commands/TrimCommands.cs +++ b/Promete/Graphics/Rendering/Commands/TrimCommands.cs @@ -8,7 +8,7 @@ public sealed class BeginTrimCommand : IRenderCommand /// トリム矩形のX座標(物理ピクセル、左端基準) public required int X { get; init; } - /// トリム矩形のY座標(物理ピクセル、下端基準) + /// トリム矩形のY座標(物理ピクセル、左上原点・上端基準) public required int Y { get; init; } /// トリム矩形の幅(物理ピクセル) diff --git a/Promete/Graphics/Rendering/GL/Runners/GLBeginTrimCommandRunner.cs b/Promete/Graphics/Rendering/GL/Runners/GLBeginTrimCommandRunner.cs index 45e8b66..2c69feb 100644 --- a/Promete/Graphics/Rendering/GL/Runners/GLBeginTrimCommandRunner.cs +++ b/Promete/Graphics/Rendering/GL/Runners/GLBeginTrimCommandRunner.cs @@ -15,7 +15,12 @@ public class GLBeginTrimCommandRunner(IGameView view) : CommandRunner ctx.WindowSize.Y) size.Y = ctx.WindowSize.Y - left.Y; - // OpenGL の Scissor は左下原点なので Y を反転 - var flippedY = ctx.WindowSize.Y - left.Y - size.Y; - + // 座標は左上原点で保持する。バックエンド固有の変換(GL の左下原点への Y 反転など)はランナー側で行う var sx = (float)left.X; - var sy = (float)flippedY; + var sy = (float)left.Y; var sw = (float)size.X; var sh = (float)size.Y; From e6451f76c7a2a0df3ec997b7cca8c5463e9c42f8 Mon Sep 17 00:00:00 2001 From: Ebise Lutica <7106976+EbiseLutica@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:13:42 +0900 Subject: [PATCH 04/16] =?UTF-8?q?feat(Backends):=20Vulkan=E3=83=90?= =?UTF-8?q?=E3=83=83=E3=82=AF=E3=82=A8=E3=83=B3=E3=83=89=E3=81=AE=E9=AA=A8?= =?UTF-8?q?=E6=A0=BC=E3=82=92=E8=BF=BD=E5=8A=A0=20(Phase=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VulkanContext: instance / surface / device / swapchain / フレーム同期 (frames-in-flight=2)・クリアカラー描画・リサイズ時の再構築 - VulkanDesktopBackend / VulkanDesktopGameView / BuildWithVulkanDesktop() - テクスチャ・シェーダー等は暫定的に Headless 実装を流用 (Phase 2 で置換) - VULKAN_PORTING_PLAN.md: コードベース調査に基づく移植計画書 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016A9ZxvQBn4vAu8Erah3rHr --- .../Backends/Vulkan/VulkanDesktopBackend.cs | 110 +++ .../Backends/Vulkan/VulkanDesktopGameView.cs | 191 +++++ .../Rendering/Vulkan/VulkanContext.cs | 751 ++++++++++++++++++ .../VulkanDesktopAppExtension.cs | 30 + VULKAN_PORTING_PLAN.md | 128 +++ 5 files changed, 1210 insertions(+) create mode 100644 Promete/Backends/Vulkan/VulkanDesktopBackend.cs create mode 100644 Promete/Backends/Vulkan/VulkanDesktopGameView.cs create mode 100644 Promete/Graphics/Rendering/Vulkan/VulkanContext.cs create mode 100644 Promete/VulkanDesktop/VulkanDesktopAppExtension.cs create mode 100644 VULKAN_PORTING_PLAN.md diff --git a/Promete/Backends/Vulkan/VulkanDesktopBackend.cs b/Promete/Backends/Vulkan/VulkanDesktopBackend.cs new file mode 100644 index 0000000..47bb022 --- /dev/null +++ b/Promete/Backends/Vulkan/VulkanDesktopBackend.cs @@ -0,0 +1,110 @@ +using System; +using Promete.Backends.Headless; +using Promete.Backends.SilkNetCommon; +using Promete.Graphics; +using Promete.Graphics.Rendering.Vulkan; +using Promete.Windowing; +using Promete.Windowing.Headless; +using Silk.NET.Maths; +using Silk.NET.Windowing; +using IWindow = Silk.NET.Windowing.IWindow; +using WindowOptions = Promete.Windowing.WindowOptions; + +namespace Promete.Backends.Vulkan; + +/// +/// Vulkan を使用するデスクトップバックエンドです。 +/// +/// +/// 現在は Phase 1(骨格)段階の実装です。ウィンドウ表示・クリアカラー描画・リサイズ対応のみを行い、 +/// テクスチャ・シェーダー・RenderTexture・画面ブリットは暫定的に Headless 実装を流用しています。 +/// 描画コマンドのランナーは未登録のため、ノードは描画されません。 +/// 詳細は VULKAN_PORTING_PLAN.md を参照してください。 +/// +public class VulkanDesktopBackend : BackendBase +{ + private SilkNetCommonTimeProvider _time = null!; + private IWindow _nativeWindow = null!; + private PrometeApp _app = null!; + private VulkanDesktopGameView _gameView = null!; + private VulkanContext? _context; + private HeadlessRenderTextureProvider _renderTextureProvider = null!; + private HeadlessScreenBlitter _screenBlitter = null!; + + public override void OnInitialize(PrometeApp app, WindowOptions opts) + { + _app = app; + var silkOptions = Silk.NET.Windowing.WindowOptions.DefaultVulkan; + silkOptions.Position = new Vector2D(opts.Location.X, opts.Location.Y); + silkOptions.Size = new Vector2D(opts.Size.X, opts.Size.Y) * opts.Scale; + silkOptions.Title = opts.Title; + silkOptions.WindowBorder = opts.Mode switch + { + WindowMode.Fixed => WindowBorder.Fixed, + WindowMode.NoFrame => WindowBorder.Hidden, + WindowMode.Resizable => WindowBorder.Resizable, + _ => throw new ArgumentException(null, nameof(opts)), + }; + silkOptions.WindowState = opts.IsFullScreen ? WindowState.Fullscreen : WindowState.Normal; + silkOptions.FramesPerSecond = opts.TargetFps; + silkOptions.UpdatesPerSecond = opts.TargetUps; + silkOptions.VSync = opts.IsVsyncMode; + + _nativeWindow = Window.Create(silkOptions); + + _nativeWindow.Load += OnLoad; + _nativeWindow.Render += OnRenderFrame; + _nativeWindow.Update += _ => _app.OnUpdate(); + _nativeWindow.Closing += () => + { + app.OnDestroy(); + _context?.Dispose(); + }; + + _time = new SilkNetCommonTimeProvider(_nativeWindow); + _gameView = new VulkanDesktopGameView(_app, _nativeWindow); + _renderTextureProvider = new HeadlessRenderTextureProvider(); + _screenBlitter = new HeadlessScreenBlitter(_renderTextureProvider, _gameView); + } + + public override ITimeProvider SetupTimeProvider() => _time; + + public override IGameView SetupGameView() => _gameView; + + public override InputProvider SetupInputProvider() => new(_nativeWindow); + + public override IScreenBlitter SetupScreenBlitter() => _screenBlitter; + + // TODO: Phase 2 で Vulkan 実装に置き換える + public override TextureFactoryBase SetupTextureFactory() => new HeadlessTextureFactory(); + + public override IRenderTextureProvider SetupRenderTextureProvider() => _renderTextureProvider; + + // TODO: Phase 2 で shaderc による Vulkan 実装に置き換える + public override IShaderFactory SetupShaderFactory() => new HeadlessShaderFactory(); + + public override void OnStart(PrometeApp app) + { + _nativeWindow.Run(); + } + + public override void OnExit(PrometeApp app) + { + _nativeWindow.Close(); + } + + private void OnLoad() + { + _context = new VulkanContext(_nativeWindow); + _context.Initialize(_nativeWindow.Title); + } + + private void OnRenderFrame(double delta) + { + // ノード走査とコマンドキュー処理(ランナー未登録のため現状は収集のみ) + _app.OnRender(); + + // TODO: Phase 3 でコマンドキューの実行結果を統合する。現状はクリアカラーのみ描画する + _context?.DrawFrame(_app.BackgroundColor); + } +} diff --git a/Promete/Backends/Vulkan/VulkanDesktopGameView.cs b/Promete/Backends/Vulkan/VulkanDesktopGameView.cs new file mode 100644 index 0000000..8abd022 --- /dev/null +++ b/Promete/Backends/Vulkan/VulkanDesktopGameView.cs @@ -0,0 +1,191 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Promete.Graphics; +using Promete.Platforms; +using Promete.Windowing; +using Silk.NET.Maths; +using Silk.NET.Windowing; +using IWindow = Silk.NET.Windowing.IWindow; + +namespace Promete.Backends.Vulkan; + +/// +/// Vulkan デスクトップバックエンドにおける の実装です。 +/// +public class VulkanDesktopGameView : IGameView +{ + private readonly PrometeApp _app; + + public VulkanDesktopGameView(PrometeApp app, IWindow window) + { + NativeWindow = window; + _app = app; + NativeWindow.Load += OnLoad; + NativeWindow.Resize += OnResize; + NativeWindow.FileDrop += OnFileDrop; + window.FocusChanged += v => IsFocused = v; + } + + public event Action? FileDropped; + public event Action? Resize; + + public IWindow NativeWindow { get; } + + public VectorInt Location + { + get => (NativeWindow.Position.X, NativeWindow.Position.Y); + set => NativeWindow.Position = new Vector2D(value.X, value.Y); + } + + public VectorInt Size + { + get; + set + { + if (field == value) + return; + field = value; + UpdateWindowSize(); + } + } = (640, 480); + + public VectorInt ActualSize => + new VectorInt(NativeWindow.FramebufferSize.X, NativeWindow.FramebufferSize.Y) / Scale; + + public int Scale + { + get; + set + { + if (value is not 1 and not 2 and not 4 and not 8) + throw new ArgumentOutOfRangeException( + nameof(value), + "Scale must be 1, 2, 4, or 8." + ); + field = value; + UpdateWindowSize(); + } + } = 1; + + public int X + { + get => Location.X; + set => Location = (value, Y); + } + + public int Y + { + get => Location.Y; + set => Location = (X, value); + } + + public int Width + { + get => Size.X; + set => Size = (value, Height); + } + + public int Height + { + get => Size.Y; + set => Size = (Width, value); + } + + public int ActualWidth => ActualSize.X; + + public int ActualHeight => ActualSize.Y; + + public bool IsVisible + { + get => NativeWindow.IsVisible; + set => NativeWindow.IsVisible = value; + } + + public bool IsFocused { get; private set; } + + public bool IsFullScreen + { + get => NativeWindow.WindowState == WindowState.Fullscreen; + set => NativeWindow.WindowState = value ? WindowState.Fullscreen : WindowState.Normal; + } + + public bool TopMost + { + get => NativeWindow.TopMost; + set => NativeWindow.TopMost = value; + } + + public float PixelRatio => + NativeWindow.Size.X == 0 ? 1 : NativeWindow.FramebufferSize.X / NativeWindow.Size.X; + + public string Title + { + get => NativeWindow.Title; + set + { + if (NativeWindow.Title == value) + return; + NativeWindow.Title = value; + MacNativeHelper.SetMenuBarTitle(value); + } + } + + public WindowMode Mode + { + get => + NativeWindow.WindowBorder switch + { + WindowBorder.Fixed => WindowMode.Fixed, + WindowBorder.Hidden => WindowMode.NoFrame, + WindowBorder.Resizable => WindowMode.Resizable, + _ => throw new InvalidOperationException("unexpected window state"), + }; + set => + NativeWindow.WindowBorder = value switch + { + WindowMode.Fixed => WindowBorder.Fixed, + WindowMode.NoFrame => WindowBorder.Hidden, + WindowMode.Resizable => WindowBorder.Resizable, + _ => throw new ArgumentException(null, nameof(value)), + }; + } + + // TODO: Phase 4 で vkCmdCopyImageToBuffer によるスクリーンショットを実装する + public Texture2D TakeScreenshot() + { + throw new NotSupportedException( + "Vulkan バックエンドはまだスクリーンショットをサポートしていません。" + ); + } + + public Task SaveScreenshotAsync(string path, CancellationToken ct = default) + { + throw new NotSupportedException( + "Vulkan バックエンドはまだスクリーンショットをサポートしていません。" + ); + } + + public void UpdateWindowSize() + { + NativeWindow.Size = new Vector2D(Size.X, Size.Y) * Scale; + } + + private void OnLoad() + { + UpdateWindowSize(); + + _app.OnStart(); + } + + private void OnResize(Vector2D vec) + { + Size = (VectorInt)((Vector)ActualSize / PixelRatio); + Resize?.Invoke(); + } + + private void OnFileDrop(string[] files) + { + FileDropped?.Invoke(new FileDroppedEventArgs(files)); + } +} diff --git a/Promete/Graphics/Rendering/Vulkan/VulkanContext.cs b/Promete/Graphics/Rendering/Vulkan/VulkanContext.cs new file mode 100644 index 0000000..8073cd4 --- /dev/null +++ b/Promete/Graphics/Rendering/Vulkan/VulkanContext.cs @@ -0,0 +1,751 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using Silk.NET.Core; +using Silk.NET.Core.Native; +using Silk.NET.Vulkan; +using Silk.NET.Vulkan.Extensions.KHR; +using Silk.NET.Windowing; +using Semaphore = Silk.NET.Vulkan.Semaphore; + +namespace Promete.Graphics.Rendering.Vulkan; + +/// +/// Vulkan のインスタンス・デバイス・スワップチェーン・フレーム同期を管理するコンテキストです。 +/// Vulkan バックエンドの中核となる低レベルオブジェクトを保持します。 +/// +internal sealed unsafe class VulkanContext : IDisposable +{ + private const int MaxFramesInFlight = 2; + + private readonly IWindow _window; + + private KhrSurface _khrSurface = null!; + private KhrSwapchain _khrSwapchain = null!; + + private Instance _instance; + private SurfaceKHR _surface; + private PhysicalDevice _physicalDevice; + private Device _device; + private uint _queueFamilyIndex; + private Queue _graphicsQueue; + + private SwapchainKHR _swapchain; + private Format _swapchainFormat; + private Extent2D _swapchainExtent; + private Image[] _swapchainImages = []; + private ImageView[] _swapchainImageViews = []; + private Framebuffer[] _framebuffers = []; + + private RenderPass _renderPass; + private CommandPool _commandPool; + private CommandBuffer[] _commandBuffers = []; + + private Semaphore[] _imageAvailableSemaphores = []; + private Semaphore[] _renderFinishedSemaphores = []; + private Fence[] _inFlightFences = []; + + private int _currentFrame; + private bool _framebufferResized; + private bool _disposed; + + public VulkanContext(IWindow window) + { + _window = window; + _window.FramebufferResize += _ => _framebufferResized = true; + } + + /// Vulkan API のエントリポイントを取得します。 + public Vk Vk { get; } = Vk.GetApi(); + + /// 論理デバイスを取得します。 + public Device Device => _device; + + /// 物理デバイスを取得します。 + public PhysicalDevice PhysicalDevice => _physicalDevice; + + /// グラフィックス兼プレゼントキューを取得します。 + public Queue GraphicsQueue => _graphicsQueue; + + /// スワップチェーンのフォーマットを取得します。 + public Format SwapchainFormat => _swapchainFormat; + + /// + /// Vulkan オブジェクトを初期化します。ウィンドウのロード後(サーフェスが取得可能になった後)に呼び出してください。 + /// + public void Initialize(string appName) + { + CreateInstance(appName); + CreateSurface(); + PickPhysicalDevice(); + CreateLogicalDevice(); + CreateSwapchain(); + CreateImageViews(); + CreateRenderPass(); + CreateFramebuffers(); + CreateCommandPool(); + CreateCommandBuffers(); + CreateSyncObjects(); + } + + /// + /// 1 フレームを描画します。現状はクリアカラーで塗りつぶすのみです。 + /// TODO: Phase 3 でレンダリングコマンドキューの実行結果をここに統合する。 + /// + public void DrawFrame(Color clearColor) + { + // 最小化中などフレームバッファサイズが 0 の間は描画しない + var fb = _window.FramebufferSize; + if (fb.X <= 0 || fb.Y <= 0) + return; + + var vk = Vk; + var fence = _inFlightFences[_currentFrame]; + vk.WaitForFences(_device, 1, in fence, true, ulong.MaxValue); + + uint imageIndex = 0; + var result = _khrSwapchain.AcquireNextImage( + _device, + _swapchain, + ulong.MaxValue, + _imageAvailableSemaphores[_currentFrame], + default, + ref imageIndex + ); + + if (result == Result.ErrorOutOfDateKhr) + { + RecreateSwapchain(); + return; + } + + if (result != Result.Success && result != Result.SuboptimalKhr) + throw new InvalidOperationException($"スワップチェーンイメージの取得に失敗しました: {result}"); + + vk.ResetFences(_device, 1, in fence); + + var cmd = _commandBuffers[_currentFrame]; + vk.ResetCommandBuffer(cmd, 0); + RecordCommandBuffer(cmd, imageIndex, clearColor); + + var waitSemaphore = _imageAvailableSemaphores[_currentFrame]; + var signalSemaphore = _renderFinishedSemaphores[_currentFrame]; + var waitStage = PipelineStageFlags.ColorAttachmentOutputBit; + + var submitInfo = new SubmitInfo + { + SType = StructureType.SubmitInfo, + WaitSemaphoreCount = 1, + PWaitSemaphores = &waitSemaphore, + PWaitDstStageMask = &waitStage, + CommandBufferCount = 1, + PCommandBuffers = &cmd, + SignalSemaphoreCount = 1, + PSignalSemaphores = &signalSemaphore, + }; + + ThrowIfFailed(vk.QueueSubmit(_graphicsQueue, 1, in submitInfo, fence), "キューの送信"); + + var swapchain = _swapchain; + var presentInfo = new PresentInfoKHR + { + SType = StructureType.PresentInfoKhr, + WaitSemaphoreCount = 1, + PWaitSemaphores = &signalSemaphore, + SwapchainCount = 1, + PSwapchains = &swapchain, + PImageIndices = &imageIndex, + }; + + result = _khrSwapchain.QueuePresent(_graphicsQueue, in presentInfo); + + if (result is Result.ErrorOutOfDateKhr or Result.SuboptimalKhr || _framebufferResized) + { + _framebufferResized = false; + RecreateSwapchain(); + } + else if (result != Result.Success) + { + throw new InvalidOperationException($"プレゼントに失敗しました: {result}"); + } + + _currentFrame = (_currentFrame + 1) % MaxFramesInFlight; + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + + var vk = Vk; + vk.DeviceWaitIdle(_device); + + CleanupSwapchain(); + + for (var i = 0; i < MaxFramesInFlight; i++) + { + vk.DestroySemaphore(_device, _imageAvailableSemaphores[i], null); + vk.DestroySemaphore(_device, _renderFinishedSemaphores[i], null); + vk.DestroyFence(_device, _inFlightFences[i], null); + } + + vk.DestroyCommandPool(_device, _commandPool, null); + vk.DestroyRenderPass(_device, _renderPass, null); + vk.DestroyDevice(_device, null); + _khrSurface.DestroySurface(_instance, _surface, null); + vk.DestroyInstance(_instance, null); + + _khrSwapchain.Dispose(); + _khrSurface.Dispose(); + vk.Dispose(); + } + + private static void ThrowIfFailed(Result result, string operation) + { + if (result != Result.Success) + throw new InvalidOperationException($"{operation}に失敗しました: {result}"); + } + + // --- 初期化 --- + private void CreateInstance(string appName) + { + var vk = Vk; + + var appNamePtr = (byte*)SilkMarshal.StringToPtr(appName); + var engineNamePtr = (byte*)SilkMarshal.StringToPtr("Promete"); + + var appInfo = new ApplicationInfo + { + SType = StructureType.ApplicationInfo, + PApplicationName = appNamePtr, + ApplicationVersion = new Version32(1, 0, 0), + PEngineName = engineNamePtr, + EngineVersion = new Version32(2, 0, 0), + ApiVersion = Vk.Version12, + }; + + var surfaceExtensions = _window.VkSurface!.GetRequiredExtensions(out var extensionCount); + + var enabledLayers = GetAvailableValidationLayers(); + var layersPtr = enabledLayers.Length > 0 + ? (byte**)SilkMarshal.StringArrayToPtr(enabledLayers) + : null; + + var createInfo = new InstanceCreateInfo + { + SType = StructureType.InstanceCreateInfo, + PApplicationInfo = &appInfo, + EnabledExtensionCount = extensionCount, + PpEnabledExtensionNames = surfaceExtensions, + EnabledLayerCount = (uint)enabledLayers.Length, + PpEnabledLayerNames = layersPtr, + }; + + var result = vk.CreateInstance(in createInfo, null, out _instance); + + SilkMarshal.Free((nint)appNamePtr); + SilkMarshal.Free((nint)engineNamePtr); + if (layersPtr != null) + SilkMarshal.Free((nint)layersPtr); + + ThrowIfFailed(result, "Vulkan インスタンスの作成"); + + if (!vk.TryGetInstanceExtension(_instance, out _khrSurface)) + throw new InvalidOperationException("VK_KHR_surface 拡張が利用できません。"); + } + + private string[] GetAvailableValidationLayers() + { +#if DEBUG + const string validationLayerName = "VK_LAYER_KHRONOS_validation"; + + var vk = Vk; + uint layerCount = 0; + vk.EnumerateInstanceLayerProperties(ref layerCount, null); + var layers = new LayerProperties[layerCount]; + fixed (LayerProperties* p = layers) + { + vk.EnumerateInstanceLayerProperties(ref layerCount, p); + } + + foreach (var layer in layers) + { + var name = SilkMarshal.PtrToString((nint)layer.LayerName); + if (name == validationLayerName) + return [validationLayerName]; + } +#endif + return []; + } + + private void CreateSurface() + { + _surface = _window + .VkSurface!.Create(_instance.ToHandle(), null) + .ToSurface(); + } + + private void PickPhysicalDevice() + { + var vk = Vk; + + uint deviceCount = 0; + vk.EnumeratePhysicalDevices(_instance, ref deviceCount, null); + if (deviceCount == 0) + throw new NotSupportedException("Vulkan をサポートする GPU が見つかりませんでした。"); + + var devices = new PhysicalDevice[deviceCount]; + fixed (PhysicalDevice* p = devices) + { + vk.EnumeratePhysicalDevices(_instance, ref deviceCount, p); + } + + // グラフィックスとプレゼントを両方サポートするキューファミリーを持つデバイスを選ぶ。 + // ディスクリート GPU を優先する。 + PhysicalDevice? fallback = null; + uint fallbackQueueFamily = 0; + + foreach (var device in devices) + { + if (!TryFindQueueFamily(device, out var queueFamily)) + continue; + if (!SupportsSwapchain(device)) + continue; + + vk.GetPhysicalDeviceProperties(device, out var props); + if (props.DeviceType == PhysicalDeviceType.DiscreteGpu) + { + _physicalDevice = device; + _queueFamilyIndex = queueFamily; + return; + } + + fallback ??= device; + if (fallback.Value.Handle == device.Handle) + fallbackQueueFamily = queueFamily; + } + + _physicalDevice = + fallback + ?? throw new NotSupportedException("要件を満たす Vulkan デバイスが見つかりませんでした。"); + _queueFamilyIndex = fallbackQueueFamily; + } + + private bool TryFindQueueFamily(PhysicalDevice device, out uint queueFamilyIndex) + { + var vk = Vk; + + uint count = 0; + vk.GetPhysicalDeviceQueueFamilyProperties(device, ref count, null); + var families = new QueueFamilyProperties[count]; + fixed (QueueFamilyProperties* p = families) + { + vk.GetPhysicalDeviceQueueFamilyProperties(device, ref count, p); + } + + for (uint i = 0; i < count; i++) + { + if ((families[i].QueueFlags & QueueFlags.GraphicsBit) == 0) + continue; + + _khrSurface.GetPhysicalDeviceSurfaceSupport(device, i, _surface, out var presentSupported); + if (!presentSupported) + continue; + + queueFamilyIndex = i; + return true; + } + + queueFamilyIndex = 0; + return false; + } + + private bool SupportsSwapchain(PhysicalDevice device) + { + var vk = Vk; + + uint count = 0; + vk.EnumerateDeviceExtensionProperties(device, (byte*)null, ref count, null); + var extensions = new ExtensionProperties[count]; + fixed (ExtensionProperties* p = extensions) + { + vk.EnumerateDeviceExtensionProperties(device, (byte*)null, ref count, p); + } + + foreach (var ext in extensions) + { + var name = SilkMarshal.PtrToString((nint)ext.ExtensionName); + if (name == KhrSwapchain.ExtensionName) + return true; + } + + return false; + } + + private void CreateLogicalDevice() + { + var vk = Vk; + + var queuePriority = 1f; + var queueCreateInfo = new DeviceQueueCreateInfo + { + SType = StructureType.DeviceQueueCreateInfo, + QueueFamilyIndex = _queueFamilyIndex, + QueueCount = 1, + PQueuePriorities = &queuePriority, + }; + + var extensionsPtr = (byte**)SilkMarshal.StringArrayToPtr([KhrSwapchain.ExtensionName]); + PhysicalDeviceFeatures features = default; + + var createInfo = new DeviceCreateInfo + { + SType = StructureType.DeviceCreateInfo, + QueueCreateInfoCount = 1, + PQueueCreateInfos = &queueCreateInfo, + EnabledExtensionCount = 1, + PpEnabledExtensionNames = extensionsPtr, + PEnabledFeatures = &features, + }; + + var result = vk.CreateDevice(_physicalDevice, in createInfo, null, out _device); + SilkMarshal.Free((nint)extensionsPtr); + ThrowIfFailed(result, "論理デバイスの作成"); + + vk.GetDeviceQueue(_device, _queueFamilyIndex, 0, out _graphicsQueue); + + if (!vk.TryGetDeviceExtension(_instance, _device, out _khrSwapchain)) + throw new InvalidOperationException("VK_KHR_swapchain 拡張が利用できません。"); + } + + private void CreateSwapchain() + { + _khrSurface.GetPhysicalDeviceSurfaceCapabilities(_physicalDevice, _surface, out var caps); + + var format = ChooseSurfaceFormat(); + var presentMode = ChoosePresentMode(); + var extent = ChooseExtent(caps); + + var imageCount = caps.MinImageCount + 1; + if (caps.MaxImageCount > 0 && imageCount > caps.MaxImageCount) + imageCount = caps.MaxImageCount; + + var createInfo = new SwapchainCreateInfoKHR + { + SType = StructureType.SwapchainCreateInfoKhr, + Surface = _surface, + MinImageCount = imageCount, + ImageFormat = format.Format, + ImageColorSpace = format.ColorSpace, + ImageExtent = extent, + ImageArrayLayers = 1, + ImageUsage = ImageUsageFlags.ColorAttachmentBit, + ImageSharingMode = SharingMode.Exclusive, + PreTransform = caps.CurrentTransform, + CompositeAlpha = CompositeAlphaFlagsKHR.OpaqueBitKhr, + PresentMode = presentMode, + Clipped = true, + }; + + ThrowIfFailed( + _khrSwapchain.CreateSwapchain(_device, in createInfo, null, out _swapchain), + "スワップチェーンの作成" + ); + + _swapchainFormat = format.Format; + _swapchainExtent = extent; + + uint actualImageCount = 0; + _khrSwapchain.GetSwapchainImages(_device, _swapchain, ref actualImageCount, null); + _swapchainImages = new Image[actualImageCount]; + fixed (Image* p = _swapchainImages) + { + _khrSwapchain.GetSwapchainImages(_device, _swapchain, ref actualImageCount, p); + } + } + + private SurfaceFormatKHR ChooseSurfaceFormat() + { + uint count = 0; + _khrSurface.GetPhysicalDeviceSurfaceFormats(_physicalDevice, _surface, ref count, null); + var formats = new SurfaceFormatKHR[count]; + fixed (SurfaceFormatKHR* p = formats) + { + _khrSurface.GetPhysicalDeviceSurfaceFormats(_physicalDevice, _surface, ref count, p); + } + + foreach (var f in formats) + { + if (f.Format == Format.B8G8R8A8Unorm && f.ColorSpace == ColorSpaceKHR.SpaceSrgbNonlinearKhr) + return f; + } + + return formats[0]; + } + + private PresentModeKHR ChoosePresentMode() + { + // VSync 有効時、または非対応環境では FIFO(常にサポートされる) + if (_window.VSync) + return PresentModeKHR.FifoKhr; + + uint count = 0; + _khrSurface.GetPhysicalDeviceSurfacePresentModes(_physicalDevice, _surface, ref count, null); + var modes = new PresentModeKHR[count]; + fixed (PresentModeKHR* p = modes) + { + _khrSurface.GetPhysicalDeviceSurfacePresentModes(_physicalDevice, _surface, ref count, p); + } + + foreach (var mode in modes) + { + if (mode == PresentModeKHR.MailboxKhr) + return mode; + } + + return PresentModeKHR.FifoKhr; + } + + private Extent2D ChooseExtent(SurfaceCapabilitiesKHR caps) + { + if (caps.CurrentExtent.Width != uint.MaxValue) + return caps.CurrentExtent; + + var fb = _window.FramebufferSize; + return new Extent2D( + Math.Clamp((uint)fb.X, caps.MinImageExtent.Width, caps.MaxImageExtent.Width), + Math.Clamp((uint)fb.Y, caps.MinImageExtent.Height, caps.MaxImageExtent.Height) + ); + } + + private void CreateImageViews() + { + var vk = Vk; + _swapchainImageViews = new ImageView[_swapchainImages.Length]; + + for (var i = 0; i < _swapchainImages.Length; i++) + { + var createInfo = new ImageViewCreateInfo + { + SType = StructureType.ImageViewCreateInfo, + Image = _swapchainImages[i], + ViewType = ImageViewType.Type2D, + Format = _swapchainFormat, + SubresourceRange = new ImageSubresourceRange(ImageAspectFlags.ColorBit, 0, 1, 0, 1), + }; + + ThrowIfFailed( + vk.CreateImageView(_device, in createInfo, null, out _swapchainImageViews[i]), + "イメージビューの作成" + ); + } + } + + private void CreateRenderPass() + { + var vk = Vk; + + var colorAttachment = new AttachmentDescription + { + Format = _swapchainFormat, + Samples = SampleCountFlags.Count1Bit, + LoadOp = AttachmentLoadOp.Clear, + StoreOp = AttachmentStoreOp.Store, + StencilLoadOp = AttachmentLoadOp.DontCare, + StencilStoreOp = AttachmentStoreOp.DontCare, + InitialLayout = ImageLayout.Undefined, + FinalLayout = ImageLayout.PresentSrcKhr, + }; + + var colorRef = new AttachmentReference(0, ImageLayout.ColorAttachmentOptimal); + + var subpass = new SubpassDescription + { + PipelineBindPoint = PipelineBindPoint.Graphics, + ColorAttachmentCount = 1, + PColorAttachments = &colorRef, + }; + + var dependency = new SubpassDependency + { + SrcSubpass = Vk.SubpassExternal, + DstSubpass = 0, + SrcStageMask = PipelineStageFlags.ColorAttachmentOutputBit, + SrcAccessMask = 0, + DstStageMask = PipelineStageFlags.ColorAttachmentOutputBit, + DstAccessMask = AccessFlags.ColorAttachmentWriteBit, + }; + + var createInfo = new RenderPassCreateInfo + { + SType = StructureType.RenderPassCreateInfo, + AttachmentCount = 1, + PAttachments = &colorAttachment, + SubpassCount = 1, + PSubpasses = &subpass, + DependencyCount = 1, + PDependencies = &dependency, + }; + + ThrowIfFailed( + vk.CreateRenderPass(_device, in createInfo, null, out _renderPass), + "レンダーパスの作成" + ); + } + + private void CreateFramebuffers() + { + var vk = Vk; + _framebuffers = new Framebuffer[_swapchainImageViews.Length]; + + for (var i = 0; i < _swapchainImageViews.Length; i++) + { + var attachment = _swapchainImageViews[i]; + var createInfo = new FramebufferCreateInfo + { + SType = StructureType.FramebufferCreateInfo, + RenderPass = _renderPass, + AttachmentCount = 1, + PAttachments = &attachment, + Width = _swapchainExtent.Width, + Height = _swapchainExtent.Height, + Layers = 1, + }; + + ThrowIfFailed( + vk.CreateFramebuffer(_device, in createInfo, null, out _framebuffers[i]), + "フレームバッファの作成" + ); + } + } + + private void CreateCommandPool() + { + var createInfo = new CommandPoolCreateInfo + { + SType = StructureType.CommandPoolCreateInfo, + Flags = CommandPoolCreateFlags.ResetCommandBufferBit, + QueueFamilyIndex = _queueFamilyIndex, + }; + + ThrowIfFailed( + Vk.CreateCommandPool(_device, in createInfo, null, out _commandPool), + "コマンドプールの作成" + ); + } + + private void CreateCommandBuffers() + { + _commandBuffers = new CommandBuffer[MaxFramesInFlight]; + + var allocInfo = new CommandBufferAllocateInfo + { + SType = StructureType.CommandBufferAllocateInfo, + CommandPool = _commandPool, + Level = CommandBufferLevel.Primary, + CommandBufferCount = MaxFramesInFlight, + }; + + fixed (CommandBuffer* p = _commandBuffers) + { + ThrowIfFailed(Vk.AllocateCommandBuffers(_device, in allocInfo, p), "コマンドバッファの確保"); + } + } + + private void CreateSyncObjects() + { + var vk = Vk; + _imageAvailableSemaphores = new Semaphore[MaxFramesInFlight]; + _renderFinishedSemaphores = new Semaphore[MaxFramesInFlight]; + _inFlightFences = new Fence[MaxFramesInFlight]; + + var semaphoreInfo = new SemaphoreCreateInfo { SType = StructureType.SemaphoreCreateInfo }; + var fenceInfo = new FenceCreateInfo + { + SType = StructureType.FenceCreateInfo, + Flags = FenceCreateFlags.SignaledBit, + }; + + for (var i = 0; i < MaxFramesInFlight; i++) + { + ThrowIfFailed( + vk.CreateSemaphore(_device, in semaphoreInfo, null, out _imageAvailableSemaphores[i]), + "セマフォの作成" + ); + ThrowIfFailed( + vk.CreateSemaphore(_device, in semaphoreInfo, null, out _renderFinishedSemaphores[i]), + "セマフォの作成" + ); + ThrowIfFailed( + vk.CreateFence(_device, in fenceInfo, null, out _inFlightFences[i]), + "フェンスの作成" + ); + } + } + + // --- フレーム描画 --- + private void RecordCommandBuffer(CommandBuffer cmd, uint imageIndex, Color clearColor) + { + var vk = Vk; + + var beginInfo = new CommandBufferBeginInfo { SType = StructureType.CommandBufferBeginInfo }; + ThrowIfFailed(vk.BeginCommandBuffer(cmd, in beginInfo), "コマンドバッファの記録開始"); + + var clearValue = new ClearValue( + new ClearColorValue( + clearColor.R / 255f, + clearColor.G / 255f, + clearColor.B / 255f, + clearColor.A / 255f + ) + ); + + var renderPassBegin = new RenderPassBeginInfo + { + SType = StructureType.RenderPassBeginInfo, + RenderPass = _renderPass, + Framebuffer = _framebuffers[imageIndex], + RenderArea = new Rect2D(new Offset2D(0, 0), _swapchainExtent), + ClearValueCount = 1, + PClearValues = &clearValue, + }; + + vk.CmdBeginRenderPass(cmd, in renderPassBegin, SubpassContents.Inline); + + // TODO: Phase 3 でここに描画コマンドを記録する + vk.CmdEndRenderPass(cmd); + ThrowIfFailed(vk.EndCommandBuffer(cmd), "コマンドバッファの記録終了"); + } + + // --- スワップチェーン再構築 --- + private void RecreateSwapchain() + { + var fb = _window.FramebufferSize; + if (fb.X <= 0 || fb.Y <= 0) + return; + + Vk.DeviceWaitIdle(_device); + CleanupSwapchain(); + + CreateSwapchain(); + CreateImageViews(); + CreateFramebuffers(); + } + + private void CleanupSwapchain() + { + var vk = Vk; + + foreach (var framebuffer in _framebuffers) + vk.DestroyFramebuffer(_device, framebuffer, null); + foreach (var view in _swapchainImageViews) + vk.DestroyImageView(_device, view, null); + + _khrSwapchain.DestroySwapchain(_device, _swapchain, null); + + _framebuffers = []; + _swapchainImageViews = []; + _swapchainImages = []; + } +} diff --git a/Promete/VulkanDesktop/VulkanDesktopAppExtension.cs b/Promete/VulkanDesktop/VulkanDesktopAppExtension.cs new file mode 100644 index 0000000..3cbbcba --- /dev/null +++ b/Promete/VulkanDesktop/VulkanDesktopAppExtension.cs @@ -0,0 +1,30 @@ +using Promete.Backends.Vulkan; +using Promete.Graphics.Rendering; +using Promete.Windowing; + +namespace Promete.VulkanDesktop; + +/// +/// Vulkan を使用したデスクトップアプリケーション用の拡張機能を提供するクラスです。 +/// +public static class VulkanDesktopAppExtension +{ + /// + /// PrometeApp を Vulkan デスクトップアプリケーションとして構築します。 + /// + /// + /// 実験的なバックエンドです。現在はウィンドウ表示とクリアカラー描画のみをサポートし、 + /// ノードの描画は行われません。詳細は VULKAN_PORTING_PLAN.md を参照してください。 + /// + /// PrometeAppのビルダー + /// ウィンドウの設定 + /// 構築されたPrometeAppインスタンス + public static PrometeApp BuildWithVulkanDesktop( + this PrometeApp.PrometeAppBuilder builder, + WindowOptions? opts = null + ) + { + // TODO: Phase 3 で Vulkan の CommandRunner 群を登録する + return builder.Use().Build(opts); + } +} diff --git a/VULKAN_PORTING_PLAN.md b/VULKAN_PORTING_PLAN.md new file mode 100644 index 0000000..f5e5526 --- /dev/null +++ b/VULKAN_PORTING_PLAN.md @@ -0,0 +1,128 @@ +# Promete Vulkan バックエンド移植計画 + +作成日: 2026-07-23 +対象ブランチ: v2 + +## 1. 現状分析 + +### 1.1 アーキテクチャの移植適性 + +v2 のレンダリングアーキテクチャは、バックエンド差し替えを前提とした設計が既に完成している。 + +- `BackendBase` の 7 つの `Setup*` メソッドによるプロバイダー注入 +- `RenderCommandQueue` + `CommandRunner` によるコマンドキュー方式(ノード側は完全にバックエンド非依存) +- `HeadlessBackend` が第2バックエンドとして抽象境界の妥当性を実証済み + +OpenGL 依存コードは以下の 16 ファイル・約 2,600 行に限定されている。 + +| 領域 | ファイル | +|------|----------| +| バックエンド本体 | `Backends/GL/OpenGLDesktopBackend.cs`, `OpenGLDesktopGameView.cs` | +| ビルド拡張・画面系 | `GLDesktop/OpenGLDesktopAppExtension.cs`, `GLScreenBlitter.cs`, `GLRenderTextureProvider.cs` | +| リソース | `Graphics/Rendering/GL/GLTextureFactory.cs`, `GLShaderFactory.cs`, `GLMaterialApplier.cs` | +| ランナー | `Graphics/Rendering/GL/Runners/` 配下 8 ファイル | +| 補助 | `GLHelper.cs`, `GLRenderState.cs`, `GLMaskedContainerHelper.cs` | + +### 1.2 描画フロー(Vulkan に有利な点) + +`PrometeApp.OnRender()` は全シーン描画を `GLScreenBlitter.ScreenRenderTexture`(オフスクリーン RT)にキャプチャし、ポストプロセス(ピンポンバッファ)を経て最後に画面へブリットする。 + +つまり **スワップチェーンイメージに触れるのは最終ブリット 1 パスのみ**。シーン描画・RenderTexture・マスク処理はすべて自前管理のオフスクリーンイメージで完結するため、スワップチェーンのフォーマット・イメージ数・リサイズ対応が最終段に隔離される。Vulkan 移植において構造的に非常に有利。 + +### 1.3 標準シェーダー + +`Promete/Resources/shaders/` に GLSL `#version 330 core` が 13 本(texture / texture_instanced / primitive / pie / masked / stencil_mask / blit)。 + +## 2. 抽象の漏れ・課題一覧 + +| # | 課題 | 深刻度 | 対応方針 | +|---|------|--------|----------| +| 1 | `RenderCommandQueue.PushTrim()` が GL の左下原点前提で Y 反転している(バックエンド非依存層に GL 固有変換が混入) | 低 | **Phase 0 で修正**。キューは左上原点で保持し、Y 反転は GL ランナー側へ移動 | +| 2 | `Texture2D.Handle` / `ShaderProgram.Handle` が `int` 単一値。Vulkan では VkImage + VkImageView + VkSampler + ディスクリプタの複合 | 中 | バックエンド内のリソーステーブル(int ID → 実体構造体)で吸収。**公開 API 変更不要**。バッチ判定 (`Handle` 比較) もそのまま機能する | +| 3 | シェーダー言語: ユーザーは GL 方言 GLSL 330 ソースを `ShaderProgram.Vertex()/.Fragment()` に渡す。Vulkan は SPIR-V 必須で、GL 方言(ルーズ uniform)は Vulkan GLSL として無効 | 高 | docs は既に「シェーダー言語はバックエンド依存」と明記済み。Vulkan バックエンドでは **Vulkan 方言 GLSL 450 を受け付け、shaderc で実行時コンパイル**する | +| 4 | `Material` の名前ベース uniform 適用(`GLMaterialApplier` が `glGetUniformLocation` 相当を使用) | 高 | SPIR-V リフレクション(SPIRV-Cross / SPIRV-Reflect)で uniform 名 → UBO オフセット / binding を解決し、per-material UBO + ディスクリプタセットに変換 | +| 5 | `IRenderTextureProvider.BeginCapture()` の即時 FBO 切替セマンティクス(`IDisposable` スコープ、ネスト可) | 中 | 単一コマンドバッファに逐次記録する前提なら「現在のレンダーパスを終了 → イメージレイアウト遷移 → 対象 RT へのパス開始」で同じセマンティクスを再現可能 | +| 6 | GL 即時ステート(blend / scissor / stencil の随時切替) | 中 | scissor・viewport・stencil ref は dynamic state。blend・シェーダーの組はパイプラインキャッシュ(キー: シェーダーペア × blend × アタッチメントフォーマット)で対応 | +| 7 | `Promete.ImGui` が `Silk.NET.OpenGL.Extensions.ImGui` にハード依存(`OpenGLDesktopGameView` へキャスト) | 中 | 初期は Vulkan 非対応を明示(現状も例外スロー)。Phase 4 以降で ImGui Vulkan バックエンドを別途実装 | +| 8 | スクリーンショット(`glReadPixels`) | 低 | `vkCmdCopyImageToBuffer` + フェンス待ちで実装(最終ブリット元 RT から読むのが簡単) | +| 9 | インスタンスバッファへの `BufferSubData`(1 フレーム中に複数バッチが同一 VBO を上書き) | 中 | frames-in-flight 対応の per-frame リングバッファ(バッチごとにオフセットを進める)に変更 | + +## 3. 技術選定 + +| 項目 | 選定 | 備考 | +|------|------|------| +| Vulkan バインディング | `Silk.NET.Vulkan` (+ `Extensions.KHR`) | 既存の `Silk.NET` 2.23 メタパッケージに同梱。**追加依存なし** | +| ウィンドウ/サーフェス | 既存 `Silk.NET.Windowing`(`IWindow.VkSurface`) | `GraphicsAPI.DefaultVulkan` を指定するだけ。入力 (`InputProvider`)・時間 (`SilkNetCommonTimeProvider`) はそのまま再利用可 | +| ランタイムシェーダーコンパイル | `Silk.NET.Shaderc` + native パッケージ | カスタムシェーダー(`IShaderFactory.Compile`)用。**標準シェーダー 13 本はビルド時に SPIR-V へ事前コンパイルして埋め込みリソース化**し、shaderc の実行時依存はカスタムシェーダー使用時のみに限定する案を推奨 | +| リフレクション | `Silk.NET.SPIRV.Cross` または `SPIRV.Reflect` | Material の uniform 名 → binding/offset 解決 | +| メモリ管理 | 素朴な専用アロケーション(イメージ/バッファごとに vkAllocateMemory) | 2D エンジンでリソース数は少なく、VMA 相当は初期不要。Phase 5 で必要なら導入 | +| 同期モデル | frames-in-flight = 2、シーン描画は単一グラフィックスキュー | RT キャプチャのネストは単一コマンドバッファへの逐次記録で表現 | +| 最低要求 | Vulkan 1.2 | dynamic rendering (1.3) は使わず、互換性重視で VkRenderPass ベース。macOS は MoltenVK 経由(将来検討、初期スコープ外) | + +## 4. フェーズ計画 + +### Phase 0: 基盤整備(GL バックエンドの回帰なし)✅ 本 PR で実装 + +- `RenderCommandQueue.PushTrim()` の Y 反転を削除し、トリム座標を左上原点に統一 +- `GLBeginTrimCommandRunner` / `GLEndTrimCommandRunner` 側でビューポート高さから Y 反転 +- `TrimCommands` の XML ドキュメント修正(下端基準 → 上端基準) +- docs の誤記修正(`CommandRunner.Execute` シグネチャ等) + +### Phase 1: Vulkan バックエンド骨格 ✅ 本 PR で実装 + +- `Backends/Vulkan/VulkanDesktopBackend` / `VulkanDesktopGameView` +- `Graphics/Rendering/Vulkan/VulkanContext`: instance(デバッグ時 validation layers)/ surface / physical device 選択 / device + queue / swapchain / レンダーパス / フレーム同期 +- `VulkanDesktop/VulkanDesktopAppExtension.BuildWithVulkanDesktop()` +- 到達点: **クリアカラーの表示とリサイズ対応**。TextureFactory / ShaderFactory / RenderTextureProvider / ScreenBlitter は暫定的に Headless 実装を流用(ランナー未登録のため描画コマンドは無視される) + +### Phase 2: リソース基盤(規模目安: 1,500 行) + +- `VulkanTextureFactory`: staging buffer 経由アップロード、Nearest サンプラー、int ID リソーステーブル、スプライトシート(アトラス + UV、ハーフテクセルインセットは既存の共通ロジックを再利用) +- `VulkanShaderFactory`: shaderc による GLSL 450 → SPIR-V コンパイル + リフレクション結果のキャッシュ +- パイプラインキャッシュ基盤 +- `VulkanRenderTextureProvider`: オフスクリーンイメージ + レンダーパス、`BeginCapture` のパス中断/再開、`Resize` +- 標準シェーダーの Vulkan GLSL 450 版を作成し、ビルド時 SPIR-V 化(MSBuild ターゲット) + +### Phase 3: ランナー移植(規模目安: 2,000 行) + +移植順(依存が少なく検証しやすい順): + +1. `VkDrawTextureBatchedCommandRunner`(インスタンシング。per-frame リングバッファ) +2. `VkBeginTrimCommandRunner` / `VkEndTrimCommandRunner`(`vkCmdSetScissor`。Vulkan は左上原点なので Phase 0 の座標がそのまま使える) +3. `VkDrawPrimitiveCommandRunner` +4. `VkDrawPieTextureCommandRunner` +5. `VulkanScreenBlitter`(ピンポンポストプロセス + スワップチェーンへの最終ブリット) +6. マスク系(`VkBeginStencilMaskCommandRunner` / `VkBeginAlphaMaskCommandRunner` / `VkEndMaskCommandRunner` + `VulkanMaskedContainerHelper`) + +各ランナーは独立性が高く、**下位モデル・サブエージェントへの委譲に適する**(GL 版という正解実装が並置されているため)。パイプライン/ディスクリプタ基盤(Phase 2)とキュー統合は委譲に不向き。 + +### Phase 4: 統合・仕上げ(規模目安: 800 行) + +- `TakeScreenshot` / `SaveScreenshotAsync`(copy image → buffer → ImageSharp) +- スワップチェーン再構築の網羅(最小化、`Scale` 変更、フルスクリーン切替) +- `Promete.Example` 全デモの動作確認(`--vulkan` 起動フラグ等でバックエンド切替できると検証が楽) +- `Promete.ImGui` の対応方針決定(Vulkan 用 ImGui レンダラー実装 or 明示的非対応の継続) +- docs 更新: `guide/extends/backend.md` にバックエンド一覧追記、`guide/graphics/shader.md` に Vulkan 方言の説明 + +### Phase 5: 検証・最適化 + +- RenderDoc での GL / Vulkan 描画結果比較(ピクセル一致確認) +- validation layers クリーン化 +- ベンチマーク(`DryRun` 計測基盤が既にあるため活用) +- 必要ならメモリアロケータ改善・ディスクリプタプール戦略見直し + +## 5. リスクと判断ポイント + +1. **カスタムシェーダーの後方互換**: GL 用に書かれたユーザーシェーダーは Vulkan バックエンドでそのまま動かない(言語方言差)。docs で明記済みの仕様として扱うが、`ShaderProgram` にバックエンド別ソースを与える API(例: `.Vertex(source, ShaderDialect.Vulkan)`)の追加は要検討。 +2. **インスタンシング頂点レイアウトとの整合**: カスタム頂点シェーダーは実際にはインスタンシング用レイアウト(location 2〜7 にインスタンス行列等、`uProjection`)を前提とする必要があるが、docs のサンプル(`shader.md` の `aPosition`/`uMvp` 例)はこれと不整合の疑いあり。Vulkan 版の設計前に GL 側で仕様を確定させるべき。 +3. **MoltenVK (macOS)**: 初期スコープ外。GL バックエンドが引き続き macOS を担う。 +4. **shaderc native 配布サイズ**: 本体 NuGet に含めるか、`Promete.Vulkan` を別パッケージに分離するか。現状は GLDesktop / Headless とも本体アセンブリ内のため、まずは本体内で進め、パッケージ分離はリリース前に判断。 + +## 6. ドキュメントのファクトチェック結果 + +| ページ | 判定 | 内容 | +|--------|------|------| +| `guide/extends/backend.md` | ✅ 正確 | `BackendBase` の 7 メソッド構成・`Build()` の説明は実装と一致 | +| `guide/extends/rendercommand.md` | ⚠️ 誤記 | `CommandRunner` 実装例が `protected override void Execute(MyCommand command, RenderContext ctx)` となっているが、実際のシグネチャは `public override void Execute(T command)`(`RenderContext` 引数なし)。本 PR で修正 | +| `guide/graphics/shader.md` | ⚠️ 要確認 | 「シェーダー言語はバックエンド依存」の注記は正確。ただしカスタム頂点シェーダーの例が v2 のインスタンシング頂点レイアウトと不整合の疑い(上記リスク 2) | +| ルート `CLAUDE.md` / `docs-llm.md` | ✅ 概ね正確 | バックエンド構成の記述は最新化済み | From 43b95913966bef530d19928deab52f7d76ac1b8d Mon Sep 17 00:00:00 2001 From: Ebise Lutica <7106976+EbiseLutica@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:22:51 +0900 Subject: [PATCH 05/16] =?UTF-8?q?feat(Experimental):=20Vulkan=E3=83=90?= =?UTF-8?q?=E3=83=83=E3=82=AF=E3=82=A8=E3=83=B3=E3=83=89=E8=B5=B7=E5=8B=95?= =?UTF-8?q?=E7=A2=BA=E8=AA=8D=E7=94=A8=E3=83=97=E3=83=AD=E3=82=B8=E3=82=A7?= =?UTF-8?q?=E3=82=AF=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BuildWithVulkanDesktop() でウィンドウを起動し、クリアカラーの描画と 切り替え・自動終了までを確認する最小プログラム。実機で起動確認済み。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016A9ZxvQBn4vAu8Erah3rHr --- Promete.Experimental.Vulkan/MainScene.cs | 43 ++++++++++++ Promete.Experimental.Vulkan/Program.cs | 18 +++++ .../Promete.Experimental.Vulkan.csproj | 15 ++++ Promete.sln | 70 +++++++++++++++++++ 4 files changed, 146 insertions(+) create mode 100644 Promete.Experimental.Vulkan/MainScene.cs create mode 100644 Promete.Experimental.Vulkan/Program.cs create mode 100644 Promete.Experimental.Vulkan/Promete.Experimental.Vulkan.csproj diff --git a/Promete.Experimental.Vulkan/MainScene.cs b/Promete.Experimental.Vulkan/MainScene.cs new file mode 100644 index 0000000..e7796e8 --- /dev/null +++ b/Promete.Experimental.Vulkan/MainScene.cs @@ -0,0 +1,43 @@ +using System.Drawing; + +namespace Promete.Experimental.Vulkan; + +/// +/// クリアカラー表示のみを行う起動確認シーン。一定フレーム経過後に自動終了します。 +/// +public class MainScene : Scene +{ + private const int ExitAfterFrames = 300; + + private int _frameCount; + + public override void OnStart() + { + App.BackgroundColor = Color.CornflowerBlue; + Console.WriteLine("[MainScene] OnStart: Vulkan バックエンド起動成功"); + Console.WriteLine($"[MainScene] Window: {Window.Size} (actual: {Window.ActualSize})"); + } + + public override void OnUpdate() + { + _frameCount++; + + // クリアカラーの切り替わりも確認する + if (_frameCount == ExitAfterFrames / 2) + { + App.BackgroundColor = Color.DarkOrange; + Console.WriteLine("[MainScene] クリアカラーを DarkOrange に変更"); + } + + if (_frameCount >= ExitAfterFrames) + { + Console.WriteLine($"[MainScene] {ExitAfterFrames} フレーム描画完了、終了します"); + App.Exit(); + } + } + + public override void OnDestroy() + { + Console.WriteLine("[MainScene] OnDestroy"); + } +} diff --git a/Promete.Experimental.Vulkan/Program.cs b/Promete.Experimental.Vulkan/Program.cs new file mode 100644 index 0000000..0f8f9db --- /dev/null +++ b/Promete.Experimental.Vulkan/Program.cs @@ -0,0 +1,18 @@ +using Promete; +using Promete.Experimental.Vulkan; +using Promete.VulkanDesktop; +using Promete.Windowing; + +// Vulkan バックエンドの起動確認用プログラム。 +// 数秒間クリアカラーを表示し、自動終了する。例外はそのままコンソールに出る。 +var app = PrometeApp + .Create() + .BuildWithVulkanDesktop( + WindowOptions.Default with + { + Title = "Promete Vulkan Experimental", + Mode = WindowMode.Resizable, + } + ); + +return app.Run(); diff --git a/Promete.Experimental.Vulkan/Promete.Experimental.Vulkan.csproj b/Promete.Experimental.Vulkan/Promete.Experimental.Vulkan.csproj new file mode 100644 index 0000000..2722bd8 --- /dev/null +++ b/Promete.Experimental.Vulkan/Promete.Experimental.Vulkan.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + 14 + enable + enable + + + + + + + diff --git a/Promete.sln b/Promete.sln index 9367d10..e284288 100644 --- a/Promete.sln +++ b/Promete.sln @@ -17,42 +17,112 @@ Project("{54A90642-561A-4BB1-A94E-469ADEE60C69}") = "Promete.Docs", "Promete.Doc EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Promete.Test", "Promete.Test\Promete.Test.csproj", "{2E2A7804-994C-4944-9753-6C8F520649F5}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Promete.Experimental.Vulkan", "Promete.Experimental.Vulkan\Promete.Experimental.Vulkan.csproj", "{86C96AD0-8BED-4C90-B562-C008ED5A1253}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {2ACE8212-D057-459D-A088-21F734BC7A3F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2ACE8212-D057-459D-A088-21F734BC7A3F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2ACE8212-D057-459D-A088-21F734BC7A3F}.Debug|x64.ActiveCfg = Debug|Any CPU + {2ACE8212-D057-459D-A088-21F734BC7A3F}.Debug|x64.Build.0 = Debug|Any CPU + {2ACE8212-D057-459D-A088-21F734BC7A3F}.Debug|x86.ActiveCfg = Debug|Any CPU + {2ACE8212-D057-459D-A088-21F734BC7A3F}.Debug|x86.Build.0 = Debug|Any CPU {2ACE8212-D057-459D-A088-21F734BC7A3F}.Release|Any CPU.ActiveCfg = Release|Any CPU {2ACE8212-D057-459D-A088-21F734BC7A3F}.Release|Any CPU.Build.0 = Release|Any CPU + {2ACE8212-D057-459D-A088-21F734BC7A3F}.Release|x64.ActiveCfg = Release|Any CPU + {2ACE8212-D057-459D-A088-21F734BC7A3F}.Release|x64.Build.0 = Release|Any CPU + {2ACE8212-D057-459D-A088-21F734BC7A3F}.Release|x86.ActiveCfg = Release|Any CPU + {2ACE8212-D057-459D-A088-21F734BC7A3F}.Release|x86.Build.0 = Release|Any CPU {9B06C032-4FAC-4A39-B5A5-B8AB9D967C53}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9B06C032-4FAC-4A39-B5A5-B8AB9D967C53}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9B06C032-4FAC-4A39-B5A5-B8AB9D967C53}.Debug|x64.ActiveCfg = Debug|Any CPU + {9B06C032-4FAC-4A39-B5A5-B8AB9D967C53}.Debug|x64.Build.0 = Debug|Any CPU + {9B06C032-4FAC-4A39-B5A5-B8AB9D967C53}.Debug|x86.ActiveCfg = Debug|Any CPU + {9B06C032-4FAC-4A39-B5A5-B8AB9D967C53}.Debug|x86.Build.0 = Debug|Any CPU {9B06C032-4FAC-4A39-B5A5-B8AB9D967C53}.Release|Any CPU.ActiveCfg = Release|Any CPU {9B06C032-4FAC-4A39-B5A5-B8AB9D967C53}.Release|Any CPU.Build.0 = Release|Any CPU + {9B06C032-4FAC-4A39-B5A5-B8AB9D967C53}.Release|x64.ActiveCfg = Release|Any CPU + {9B06C032-4FAC-4A39-B5A5-B8AB9D967C53}.Release|x64.Build.0 = Release|Any CPU + {9B06C032-4FAC-4A39-B5A5-B8AB9D967C53}.Release|x86.ActiveCfg = Release|Any CPU + {9B06C032-4FAC-4A39-B5A5-B8AB9D967C53}.Release|x86.Build.0 = Release|Any CPU {14FD4403-27F0-4F35-8F74-33C0295BACDB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {14FD4403-27F0-4F35-8F74-33C0295BACDB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {14FD4403-27F0-4F35-8F74-33C0295BACDB}.Debug|x64.ActiveCfg = Debug|Any CPU + {14FD4403-27F0-4F35-8F74-33C0295BACDB}.Debug|x64.Build.0 = Debug|Any CPU + {14FD4403-27F0-4F35-8F74-33C0295BACDB}.Debug|x86.ActiveCfg = Debug|Any CPU + {14FD4403-27F0-4F35-8F74-33C0295BACDB}.Debug|x86.Build.0 = Debug|Any CPU {14FD4403-27F0-4F35-8F74-33C0295BACDB}.Release|Any CPU.ActiveCfg = Release|Any CPU {14FD4403-27F0-4F35-8F74-33C0295BACDB}.Release|Any CPU.Build.0 = Release|Any CPU + {14FD4403-27F0-4F35-8F74-33C0295BACDB}.Release|x64.ActiveCfg = Release|Any CPU + {14FD4403-27F0-4F35-8F74-33C0295BACDB}.Release|x64.Build.0 = Release|Any CPU + {14FD4403-27F0-4F35-8F74-33C0295BACDB}.Release|x86.ActiveCfg = Release|Any CPU + {14FD4403-27F0-4F35-8F74-33C0295BACDB}.Release|x86.Build.0 = Release|Any CPU {EDAC2C42-658C-44B7-9130-34043D729717}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {EDAC2C42-658C-44B7-9130-34043D729717}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EDAC2C42-658C-44B7-9130-34043D729717}.Debug|x64.ActiveCfg = Debug|Any CPU + {EDAC2C42-658C-44B7-9130-34043D729717}.Debug|x64.Build.0 = Debug|Any CPU + {EDAC2C42-658C-44B7-9130-34043D729717}.Debug|x86.ActiveCfg = Debug|Any CPU + {EDAC2C42-658C-44B7-9130-34043D729717}.Debug|x86.Build.0 = Debug|Any CPU {EDAC2C42-658C-44B7-9130-34043D729717}.Release|Any CPU.ActiveCfg = Release|Any CPU {EDAC2C42-658C-44B7-9130-34043D729717}.Release|Any CPU.Build.0 = Release|Any CPU + {EDAC2C42-658C-44B7-9130-34043D729717}.Release|x64.ActiveCfg = Release|Any CPU + {EDAC2C42-658C-44B7-9130-34043D729717}.Release|x64.Build.0 = Release|Any CPU + {EDAC2C42-658C-44B7-9130-34043D729717}.Release|x86.ActiveCfg = Release|Any CPU + {EDAC2C42-658C-44B7-9130-34043D729717}.Release|x86.Build.0 = Release|Any CPU {1F738BE8-66A9-4FEA-8D6E-676A2955B9B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1F738BE8-66A9-4FEA-8D6E-676A2955B9B2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1F738BE8-66A9-4FEA-8D6E-676A2955B9B2}.Debug|x64.ActiveCfg = Debug|Any CPU + {1F738BE8-66A9-4FEA-8D6E-676A2955B9B2}.Debug|x64.Build.0 = Debug|Any CPU + {1F738BE8-66A9-4FEA-8D6E-676A2955B9B2}.Debug|x86.ActiveCfg = Debug|Any CPU + {1F738BE8-66A9-4FEA-8D6E-676A2955B9B2}.Debug|x86.Build.0 = Debug|Any CPU {1F738BE8-66A9-4FEA-8D6E-676A2955B9B2}.Release|Any CPU.ActiveCfg = Release|Any CPU {1F738BE8-66A9-4FEA-8D6E-676A2955B9B2}.Release|Any CPU.Build.0 = Release|Any CPU + {1F738BE8-66A9-4FEA-8D6E-676A2955B9B2}.Release|x64.ActiveCfg = Release|Any CPU + {1F738BE8-66A9-4FEA-8D6E-676A2955B9B2}.Release|x64.Build.0 = Release|Any CPU + {1F738BE8-66A9-4FEA-8D6E-676A2955B9B2}.Release|x86.ActiveCfg = Release|Any CPU + {1F738BE8-66A9-4FEA-8D6E-676A2955B9B2}.Release|x86.Build.0 = Release|Any CPU {97F485D6-C6E7-4A60-9FFA-132FD92DD3F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {97F485D6-C6E7-4A60-9FFA-132FD92DD3F6}.Debug|Any CPU.Build.0 = Debug|Any CPU {97F485D6-C6E7-4A60-9FFA-132FD92DD3F6}.Debug|Any CPU.Deploy.0 = Debug|Any CPU + {97F485D6-C6E7-4A60-9FFA-132FD92DD3F6}.Debug|x64.ActiveCfg = Debug|x64 + {97F485D6-C6E7-4A60-9FFA-132FD92DD3F6}.Debug|x86.ActiveCfg = Debug|x86 {97F485D6-C6E7-4A60-9FFA-132FD92DD3F6}.Release|Any CPU.ActiveCfg = Release|Any CPU {97F485D6-C6E7-4A60-9FFA-132FD92DD3F6}.Release|Any CPU.Build.0 = Release|Any CPU {97F485D6-C6E7-4A60-9FFA-132FD92DD3F6}.Release|Any CPU.Deploy.0 = Release|Any CPU + {97F485D6-C6E7-4A60-9FFA-132FD92DD3F6}.Release|x64.ActiveCfg = Release|x64 + {97F485D6-C6E7-4A60-9FFA-132FD92DD3F6}.Release|x86.ActiveCfg = Release|x86 {2E2A7804-994C-4944-9753-6C8F520649F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2E2A7804-994C-4944-9753-6C8F520649F5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2E2A7804-994C-4944-9753-6C8F520649F5}.Debug|x64.ActiveCfg = Debug|Any CPU + {2E2A7804-994C-4944-9753-6C8F520649F5}.Debug|x64.Build.0 = Debug|Any CPU + {2E2A7804-994C-4944-9753-6C8F520649F5}.Debug|x86.ActiveCfg = Debug|Any CPU + {2E2A7804-994C-4944-9753-6C8F520649F5}.Debug|x86.Build.0 = Debug|Any CPU {2E2A7804-994C-4944-9753-6C8F520649F5}.Release|Any CPU.ActiveCfg = Release|Any CPU {2E2A7804-994C-4944-9753-6C8F520649F5}.Release|Any CPU.Build.0 = Release|Any CPU + {2E2A7804-994C-4944-9753-6C8F520649F5}.Release|x64.ActiveCfg = Release|Any CPU + {2E2A7804-994C-4944-9753-6C8F520649F5}.Release|x64.Build.0 = Release|Any CPU + {2E2A7804-994C-4944-9753-6C8F520649F5}.Release|x86.ActiveCfg = Release|Any CPU + {2E2A7804-994C-4944-9753-6C8F520649F5}.Release|x86.Build.0 = Release|Any CPU + {86C96AD0-8BED-4C90-B562-C008ED5A1253}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {86C96AD0-8BED-4C90-B562-C008ED5A1253}.Debug|Any CPU.Build.0 = Debug|Any CPU + {86C96AD0-8BED-4C90-B562-C008ED5A1253}.Debug|x64.ActiveCfg = Debug|Any CPU + {86C96AD0-8BED-4C90-B562-C008ED5A1253}.Debug|x64.Build.0 = Debug|Any CPU + {86C96AD0-8BED-4C90-B562-C008ED5A1253}.Debug|x86.ActiveCfg = Debug|Any CPU + {86C96AD0-8BED-4C90-B562-C008ED5A1253}.Debug|x86.Build.0 = Debug|Any CPU + {86C96AD0-8BED-4C90-B562-C008ED5A1253}.Release|Any CPU.ActiveCfg = Release|Any CPU + {86C96AD0-8BED-4C90-B562-C008ED5A1253}.Release|Any CPU.Build.0 = Release|Any CPU + {86C96AD0-8BED-4C90-B562-C008ED5A1253}.Release|x64.ActiveCfg = Release|Any CPU + {86C96AD0-8BED-4C90-B562-C008ED5A1253}.Release|x64.Build.0 = Release|Any CPU + {86C96AD0-8BED-4C90-B562-C008ED5A1253}.Release|x86.ActiveCfg = Release|Any CPU + {86C96AD0-8BED-4C90-B562-C008ED5A1253}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From f0cc50f2a9fd495bf54fb3bf50f932b36ae6ec9c Mon Sep 17 00:00:00 2001 From: Ebise Lutica <7106976+EbiseLutica@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:46:37 +0900 Subject: [PATCH 06/16] =?UTF-8?q?feat(Backends):=20Vulkan=E3=83=90?= =?UTF-8?q?=E3=83=83=E3=82=AF=E3=82=A8=E3=83=B3=E3=83=89=E3=81=A7Sprite/Sh?= =?UTF-8?q?ape=E6=8F=8F=E7=94=BB=E3=81=AB=E5=AF=BE=E5=BF=9C=20(Phase=202/3?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VulkanResourceManager: int IDテーブルでVkImage/ImageView/DescriptorSetを管理 (Texture2D.Handle 互換)、stagingバッファ経由アップロード - VulkanPipelineProvider: パイプライン遅延生成・キャッシュ、shadercによる GLSL450→SPIR-Vランタイムコンパイル (Silk.NET.Shaderc 追加) - VulkanRenderTextureProvider: オフスクリーンパスの中断/再開でBeginCapture のネストセマンティクスを再現、Resize対応 - VulkanContext: フレームライフサイクル (BeginFrame/EndFrame)、ターゲット スタック、per-frame頂点アリーナ、遅延破棄、ピクセル読み出し - ランナー: テクスチャバッチ (インスタンシング)、プリミティブ、トリム - VulkanScreenBlitter: フルスクリーントライアングルでスワップチェーンへブリット - スクリーンショット実装 (TakeScreenshot / SaveScreenshotAsync) - Promete.Experimental.Vulkan: ピクセル単位の自動検証 (6項目全パス) 未対応: カスタムシェーダー・マスク・PieSprite・ポストプロセス Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016A9ZxvQBn4vAu8Erah3rHr --- Promete.Experimental.Vulkan/MainScene.cs | 93 ++- .../Backends/Vulkan/VulkanDesktopBackend.cs | 86 +- .../Backends/Vulkan/VulkanDesktopGameView.cs | 52 +- .../VulkanDrawPrimitiveCommandRunner.cs | 149 ++++ .../VulkanDrawTextureBatchedCommandRunner.cs | 236 ++++++ .../Runners/VulkanTrimCommandRunners.cs | 51 ++ .../Rendering/Vulkan/VulkanContext.cs | 759 +++++++++++++++--- .../Rendering/Vulkan/VulkanFrameArena.cs | 108 +++ .../Vulkan/VulkanPipelineProvider.cs | 424 ++++++++++ .../Rendering/Vulkan/VulkanRenderTarget.cs | 27 + .../Vulkan/VulkanRenderTextureProvider.cs | 147 ++++ .../Rendering/Vulkan/VulkanResourceManager.cs | 332 ++++++++ .../Rendering/Vulkan/VulkanScreenBlitter.cs | 88 ++ .../Rendering/Vulkan/VulkanShaderCompiler.cs | 82 ++ .../Rendering/Vulkan/VulkanShaderFactory.cs | 19 + .../Rendering/Vulkan/VulkanTextureFactory.cs | 139 ++++ Promete/Promete.csproj | 4 + Promete/Resources/shaders/vulkan/blit.frag | 11 + Promete/Resources/shaders/vulkan/blit.vert | 10 + .../Resources/shaders/vulkan/primitive.frag | 12 + .../Resources/shaders/vulkan/primitive.vert | 8 + .../shaders/vulkan/texture_instanced.frag | 12 + .../shaders/vulkan/texture_instanced.vert | 25 + .../VulkanDesktopAppExtension.cs | 5 +- VULKAN_PORTING_PLAN.md | 14 +- 25 files changed, 2740 insertions(+), 153 deletions(-) create mode 100644 Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawPrimitiveCommandRunner.cs create mode 100644 Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawTextureBatchedCommandRunner.cs create mode 100644 Promete/Graphics/Rendering/Vulkan/Runners/VulkanTrimCommandRunners.cs create mode 100644 Promete/Graphics/Rendering/Vulkan/VulkanFrameArena.cs create mode 100644 Promete/Graphics/Rendering/Vulkan/VulkanPipelineProvider.cs create mode 100644 Promete/Graphics/Rendering/Vulkan/VulkanRenderTarget.cs create mode 100644 Promete/Graphics/Rendering/Vulkan/VulkanRenderTextureProvider.cs create mode 100644 Promete/Graphics/Rendering/Vulkan/VulkanResourceManager.cs create mode 100644 Promete/Graphics/Rendering/Vulkan/VulkanScreenBlitter.cs create mode 100644 Promete/Graphics/Rendering/Vulkan/VulkanShaderCompiler.cs create mode 100644 Promete/Graphics/Rendering/Vulkan/VulkanShaderFactory.cs create mode 100644 Promete/Graphics/Rendering/Vulkan/VulkanTextureFactory.cs create mode 100644 Promete/Resources/shaders/vulkan/blit.frag create mode 100644 Promete/Resources/shaders/vulkan/blit.vert create mode 100644 Promete/Resources/shaders/vulkan/primitive.frag create mode 100644 Promete/Resources/shaders/vulkan/primitive.vert create mode 100644 Promete/Resources/shaders/vulkan/texture_instanced.frag create mode 100644 Promete/Resources/shaders/vulkan/texture_instanced.vert diff --git a/Promete.Experimental.Vulkan/MainScene.cs b/Promete.Experimental.Vulkan/MainScene.cs index e7796e8..b09262a 100644 --- a/Promete.Experimental.Vulkan/MainScene.cs +++ b/Promete.Experimental.Vulkan/MainScene.cs @@ -1,43 +1,108 @@ using System.Drawing; +using Promete.Graphics; +using Promete.Nodes; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using Color = System.Drawing.Color; namespace Promete.Experimental.Vulkan; /// -/// クリアカラー表示のみを行う起動確認シーン。一定フレーム経過後に自動終了します。 +/// Vulkan バックエンドの描画検証シーン。 +/// スプライトとプリミティブを描画し、スクリーンショットのピクセル色を検証して終了します。 /// public class MainScene : Scene { - private const int ExitAfterFrames = 300; + private const int ScreenshotFrame = 60; + private static readonly string ScreenshotPath = Path.Combine( + AppContext.BaseDirectory, + "vulkan_test.png" + ); + + private Texture2D _redTexture; private int _frameCount; + private bool _screenshotRequested; public override void OnStart() { - App.BackgroundColor = Color.CornflowerBlue; - Console.WriteLine("[MainScene] OnStart: Vulkan バックエンド起動成功"); - Console.WriteLine($"[MainScene] Window: {Window.Size} (actual: {Window.ActualSize})"); + App.BackgroundColor = Color.DarkSlateBlue; + + // 赤い 100x100 スプライト (左上 50,50) + _redTexture = App.TextureFactory.CreateSolid(Color.Red, (100, 100)); + Root.Add(new Sprite(_redTexture).Location(50, 50)); + + // ライム色の塗りつぶし矩形 (250,100)-(350,200) + Root.Add(Shape.CreateRect(250, 100, 350, 200, Color.Lime)); + + // ティント検証用: 白テクスチャ × 青ティント (450,50) + var white = App.TextureFactory.CreateSolid(Color.White, (50, 50)); + var tinted = new Sprite(white).Location(450, 50); + tinted.TintColor = Color.Blue; + Root.Add(tinted); + + Console.WriteLine("[MainScene] OnStart: ノード配置完了"); } public override void OnUpdate() { _frameCount++; - // クリアカラーの切り替わりも確認する - if (_frameCount == ExitAfterFrames / 2) + if (_frameCount == ScreenshotFrame && !_screenshotRequested) { - App.BackgroundColor = Color.DarkOrange; - Console.WriteLine("[MainScene] クリアカラーを DarkOrange に変更"); + _screenshotRequested = true; + _ = VerifyAndExitAsync(); } + } - if (_frameCount >= ExitAfterFrames) + public override void OnDestroy() + { + _redTexture.Dispose(); + Console.WriteLine("[MainScene] OnDestroy"); + } + + private async Task VerifyAndExitAsync() + { + try { - Console.WriteLine($"[MainScene] {ExitAfterFrames} フレーム描画完了、終了します"); - App.Exit(); + await Window.SaveScreenshotAsync(ScreenshotPath); + Console.WriteLine($"[MainScene] スクリーンショット保存: {ScreenshotPath}"); + + using var img = SixLabors.ImageSharp.Image.Load(ScreenshotPath); + var failures = 0; + failures += Verify(img, 10, 10, Color.DarkSlateBlue, "背景 (左上)"); + failures += Verify(img, 100, 100, Color.Red, "赤スプライト中心"); + failures += Verify(img, 300, 150, Color.Lime, "ライム矩形中心"); + failures += Verify(img, 475, 75, Color.Blue, "青ティントスプライト"); + failures += Verify(img, 100, 400, Color.DarkSlateBlue, "背景 (下部, Y軸反転検出)"); + failures += Verify(img, 620, 460, Color.DarkSlateBlue, "背景 (右下)"); + + Console.WriteLine( + failures == 0 + ? "[MainScene] ✅ 全ピクセル検証パス" + : $"[MainScene] ❌ {failures} 件の検証失敗" + ); + App.Exit(failures == 0 ? 0 : 1); + } + catch (Exception ex) + { + Console.WriteLine($"[MainScene] ❌ 検証中に例外: {ex}"); + App.Exit(2); } } - public override void OnDestroy() + private static int Verify(Image img, int x, int y, Color expected, string label) { - Console.WriteLine("[MainScene] OnDestroy"); + var actual = img[x, y]; + var ok = + Math.Abs(actual.R - expected.R) <= 2 + && Math.Abs(actual.G - expected.G) <= 2 + && Math.Abs(actual.B - expected.B) <= 2; + + Console.WriteLine( + $"[MainScene] {(ok ? "OK" : "NG")} {label} ({x},{y}): " + + $"expected=({expected.R},{expected.G},{expected.B}) actual=({actual.R},{actual.G},{actual.B})" + ); + return ok ? 0 : 1; } } diff --git a/Promete/Backends/Vulkan/VulkanDesktopBackend.cs b/Promete/Backends/Vulkan/VulkanDesktopBackend.cs index 47bb022..2001491 100644 --- a/Promete/Backends/Vulkan/VulkanDesktopBackend.cs +++ b/Promete/Backends/Vulkan/VulkanDesktopBackend.cs @@ -1,10 +1,10 @@ using System; -using Promete.Backends.Headless; using Promete.Backends.SilkNetCommon; using Promete.Graphics; +using Promete.Graphics.Rendering; using Promete.Graphics.Rendering.Vulkan; +using Promete.Graphics.Rendering.Vulkan.Runners; using Promete.Windowing; -using Promete.Windowing.Headless; using Silk.NET.Maths; using Silk.NET.Windowing; using IWindow = Silk.NET.Windowing.IWindow; @@ -16,9 +16,8 @@ namespace Promete.Backends.Vulkan; /// Vulkan を使用するデスクトップバックエンドです。 /// /// -/// 現在は Phase 1(骨格)段階の実装です。ウィンドウ表示・クリアカラー描画・リサイズ対応のみを行い、 -/// テクスチャ・シェーダー・RenderTexture・画面ブリットは暫定的に Headless 実装を流用しています。 -/// 描画コマンドのランナーは未登録のため、ノードは描画されません。 +/// 実験的なバックエンドです。スプライト・プリミティブ・トリム・RenderTexture の描画に対応しています。 +/// カスタムシェーダー・マスク・扇形テクスチャ・ポストプロセスは未対応です。 /// 詳細は VULKAN_PORTING_PLAN.md を参照してください。 /// public class VulkanDesktopBackend : BackendBase @@ -27,9 +26,13 @@ public class VulkanDesktopBackend : BackendBase private IWindow _nativeWindow = null!; private PrometeApp _app = null!; private VulkanDesktopGameView _gameView = null!; - private VulkanContext? _context; - private HeadlessRenderTextureProvider _renderTextureProvider = null!; - private HeadlessScreenBlitter _screenBlitter = null!; + private VulkanContext _context = null!; + private VulkanResourceManager _resources = null!; + private VulkanPipelineProvider _pipelines = null!; + private VulkanTextureFactory _textureFactory = null!; + private VulkanRenderTextureProvider _renderTextureProvider = null!; + private VulkanScreenBlitter _screenBlitter = null!; + private VulkanDrawTextureBatchedCommandRunner? _textureRunner; public override void OnInitialize(PrometeApp app, WindowOptions opts) { @@ -55,16 +58,28 @@ public override void OnInitialize(PrometeApp app, WindowOptions opts) _nativeWindow.Load += OnLoad; _nativeWindow.Render += OnRenderFrame; _nativeWindow.Update += _ => _app.OnUpdate(); - _nativeWindow.Closing += () => - { - app.OnDestroy(); - _context?.Dispose(); - }; + _nativeWindow.Closing += OnClosing; + _context = new VulkanContext(_nativeWindow); + _resources = new VulkanResourceManager(_context); + _pipelines = new VulkanPipelineProvider(_context, _resources); _time = new SilkNetCommonTimeProvider(_nativeWindow); _gameView = new VulkanDesktopGameView(_app, _nativeWindow); - _renderTextureProvider = new HeadlessRenderTextureProvider(); - _screenBlitter = new HeadlessScreenBlitter(_renderTextureProvider, _gameView); + _textureFactory = new VulkanTextureFactory(_app, _resources); + _renderTextureProvider = new VulkanRenderTextureProvider(_context, _resources); + _screenBlitter = new VulkanScreenBlitter( + _context, + _resources, + _pipelines, + _renderTextureProvider, + _gameView + ); + _gameView.AttachRenderingResources( + _context, + _renderTextureProvider, + _screenBlitter, + _textureFactory + ); } public override ITimeProvider SetupTimeProvider() => _time; @@ -75,13 +90,11 @@ public override void OnInitialize(PrometeApp app, WindowOptions opts) public override IScreenBlitter SetupScreenBlitter() => _screenBlitter; - // TODO: Phase 2 で Vulkan 実装に置き換える - public override TextureFactoryBase SetupTextureFactory() => new HeadlessTextureFactory(); + public override TextureFactoryBase SetupTextureFactory() => _textureFactory; public override IRenderTextureProvider SetupRenderTextureProvider() => _renderTextureProvider; - // TODO: Phase 2 で shaderc による Vulkan 実装に置き換える - public override IShaderFactory SetupShaderFactory() => new HeadlessShaderFactory(); + public override IShaderFactory SetupShaderFactory() => new VulkanShaderFactory(); public override void OnStart(PrometeApp app) { @@ -95,16 +108,43 @@ public override void OnExit(PrometeApp app) private void OnLoad() { - _context = new VulkanContext(_nativeWindow); _context.Initialize(_nativeWindow.Title); + _screenBlitter.InitializeScreenRenderTexture(); + + // ランナーをコマンドキューへ登録する + _textureRunner = new VulkanDrawTextureBatchedCommandRunner(_context, _resources, _pipelines); + _app.GetPlugin() + .RegisterRunnerRange( + _textureRunner, + new VulkanDrawPrimitiveCommandRunner(_context, _pipelines), + new VulkanBeginTrimCommandRunner(_context), + new VulkanEndTrimCommandRunner(_context) + ); + } + + private void OnClosing() + { + _app.OnDestroy(); + + if (!_context.IsInitialized) + return; + _context.WaitIdle(); + _textureRunner?.Dispose(); + _pipelines.Dispose(); + _resources.Dispose(); + _context.Dispose(); } private void OnRenderFrame(double delta) { - // ノード走査とコマンドキュー処理(ランナー未登録のため現状は収集のみ) + if (!_context.IsInitialized) + return; + if (!_context.BeginFrame()) + return; + + // ノード走査 → コマンドキュー実行 (ScreenRenderTexture へのキャプチャ) → ブリット _app.OnRender(); - // TODO: Phase 3 でコマンドキューの実行結果を統合する。現状はクリアカラーのみ描画する - _context?.DrawFrame(_app.BackgroundColor); + _context.EndFrame(); } } diff --git a/Promete/Backends/Vulkan/VulkanDesktopGameView.cs b/Promete/Backends/Vulkan/VulkanDesktopGameView.cs index 8abd022..163a58e 100644 --- a/Promete/Backends/Vulkan/VulkanDesktopGameView.cs +++ b/Promete/Backends/Vulkan/VulkanDesktopGameView.cs @@ -2,10 +2,13 @@ using System.Threading; using System.Threading.Tasks; using Promete.Graphics; +using Promete.Graphics.Rendering.Vulkan; using Promete.Platforms; using Promete.Windowing; using Silk.NET.Maths; using Silk.NET.Windowing; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; using IWindow = Silk.NET.Windowing.IWindow; namespace Promete.Backends.Vulkan; @@ -16,6 +19,10 @@ namespace Promete.Backends.Vulkan; public class VulkanDesktopGameView : IGameView { private readonly PrometeApp _app; + private VulkanContext? _context; + private VulkanRenderTextureProvider? _renderTextureProvider; + private VulkanScreenBlitter? _screenBlitter; + private TextureFactoryBase? _textureFactory; public VulkanDesktopGameView(PrometeApp app, IWindow window) { @@ -151,19 +158,17 @@ public WindowMode Mode }; } - // TODO: Phase 4 で vkCmdCopyImageToBuffer によるスクリーンショットを実装する public Texture2D TakeScreenshot() { - throw new NotSupportedException( - "Vulkan バックエンドはまだスクリーンショットをサポートしていません。" - ); + EnsureRenderingResources(); + return _textureFactory!.LoadFromImageSharpImage(TakeScreenshotAsImage()); } - public Task SaveScreenshotAsync(string path, CancellationToken ct = default) + public async Task SaveScreenshotAsync(string path, CancellationToken ct = default) { - throw new NotSupportedException( - "Vulkan バックエンドはまだスクリーンショットをサポートしていません。" - ); + EnsureRenderingResources(); + var img = TakeScreenshotAsImage(); + await img.SaveAsPngAsync(path, ct); } public void UpdateWindowSize() @@ -171,6 +176,37 @@ public void UpdateWindowSize() NativeWindow.Size = new Vector2D(Size.X, Size.Y) * Scale; } + /// + /// スクリーンショット等に必要な内部リソースへの参照を設定します。バックエンドが呼び出します。 + /// + internal void AttachRenderingResources( + VulkanContext context, + VulkanRenderTextureProvider renderTextureProvider, + VulkanScreenBlitter screenBlitter, + TextureFactoryBase textureFactory + ) + { + _context = context; + _renderTextureProvider = renderTextureProvider; + _screenBlitter = screenBlitter; + _textureFactory = textureFactory; + } + + private void EnsureRenderingResources() + { + if (_context is not { IsInitialized: true } || _screenBlitter?.ScreenRenderTexture is null) + throw new InvalidOperationException( + "レンダリングが初期化されていないため、スクリーンショットを取得できません。" + ); + } + + private Image TakeScreenshotAsImage() + { + var target = _renderTextureProvider!.GetTarget(_screenBlitter!.ScreenRenderTexture); + var pixels = _context!.ReadImagePixels(target.Image, target.Extent.Width, target.Extent.Height); + return Image.LoadPixelData(pixels, (int)target.Extent.Width, (int)target.Extent.Height); + } + private void OnLoad() { UpdateWindowSize(); diff --git a/Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawPrimitiveCommandRunner.cs b/Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawPrimitiveCommandRunner.cs new file mode 100644 index 0000000..26b046c --- /dev/null +++ b/Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawPrimitiveCommandRunner.cs @@ -0,0 +1,149 @@ +using System; +using System.Drawing; +using System.Numerics; +using Promete.Graphics.Rendering.Commands; +using Promete.Nodes; +using Silk.NET.Vulkan; + +namespace Promete.Graphics.Rendering.Vulkan.Runners; + +/// +/// でプリミティブ図形を描画するランナーです。 +/// +internal sealed unsafe class VulkanDrawPrimitiveCommandRunner( + VulkanContext ctx, + VulkanPipelineProvider pipelines +) : CommandRunner +{ + public override void Execute(DrawPrimitiveCommand command) + { + Draw( + command.WorldVertices, + command.ShapeType, + command.Color, + command.LineWidth, + command.LineColor + ); + } + + private void Draw( + Span worldVertices, + ShapeType type, + Color color, + int lineWidth, + Color? lineColor + ) + { + PrometeApp.Current.ThrowIfNotMainThread(); + if (worldVertices.Length == 0 || !ctx.IsFrameActive) + return; + + var extent = ctx.CurrentTargetExtent; + var halfWidth = extent.Width / 2f; + var halfHeight = extent.Height / 2f; + + // ワールド座標 → Vulkan NDC (Y 下向きのため、GL 向け変換の Y を反転) + Span vertices = stackalloc float[worldVertices.Length * 2]; + for (var i = 0; i < worldVertices.Length; i++) + { + var (x, y) = worldVertices[i].ToViewportPoint(halfWidth, halfHeight); + vertices[(i * 2) + 0] = x; + vertices[(i * 2) + 1] = -y; + } + + DrawFill(vertices, type, color, lineWidth); + DrawStroke(vertices, lineWidth, lineColor); + } + + private void DrawFill(Span vertices, ShapeType type, Color color, int lineWidth) + { + // 透明度が0の場合は、塗りつぶし領域の描画をスキップする + if (color.A <= 0) + return; + + _ = lineWidth; // Vulkan では線幅 1 のみサポート (wideLines 未使用) + + var vk = ctx.Vk; + var cmd = ctx.CurrentCommandBuffer; + var vertexCount = (uint)(vertices.Length / 2); + + var (buffer, offset) = ctx.CurrentArena.Push(vertices); + + var pipeline = pipelines.GetPrimitivePipeline( + VulkanPipelineProvider.PassClass.Offscreen, + ToVulkanTopology(type) + ); + vk.CmdBindPipeline(cmd, PipelineBindPoint.Graphics, pipeline); + PushColor(cmd, color); + + vk.CmdBindVertexBuffers(cmd, 0, 1, in buffer, in offset); + + // 矩形はインデックスを利用して 2 トライアングルで描画する + if (type == ShapeType.Rect) + { + Span indices = [0, 1, 2, 0, 2, 3]; + var (indexBuffer, indexOffset) = ctx.CurrentArena.Push(indices); + vk.CmdBindIndexBuffer(cmd, indexBuffer, indexOffset, IndexType.Uint32); + vk.CmdDrawIndexed(cmd, 6, 1, 0, 0, 0); + return; + } + + vk.CmdDraw(cmd, vertexCount, 1, 0, 0); + } + + private void DrawStroke(Span vertices, int lineWidth, Color? lineColor) + { + if (lineWidth <= 0 || lineColor is not { } lc) + return; + + var vk = ctx.Vk; + var cmd = ctx.CurrentCommandBuffer; + + // LineLoop は Vulkan に無いため、先頭頂点を末尾に足して LineStrip で閉じる + Span looped = stackalloc float[vertices.Length + 2]; + vertices.CopyTo(looped); + looped[^2] = vertices[0]; + looped[^1] = vertices[1]; + + var (buffer, offset) = ctx.CurrentArena.Push(looped); + + var pipeline = pipelines.GetPrimitivePipeline( + VulkanPipelineProvider.PassClass.Offscreen, + PrimitiveTopology.LineStrip + ); + vk.CmdBindPipeline(cmd, PipelineBindPoint.Graphics, pipeline); + PushColor(cmd, lc); + + vk.CmdBindVertexBuffers(cmd, 0, 1, in buffer, in offset); + vk.CmdDraw(cmd, (uint)(looped.Length / 2), 1, 0, 0); + } + + private void PushColor(CommandBuffer cmd, Color color) + { + var value = new Vector4(color.R / 255f, color.G / 255f, color.B / 255f, color.A / 255f); + ctx.Vk.CmdPushConstants( + cmd, + pipelines.PrimitiveLayout, + ShaderStageFlags.FragmentBit, + 0, + 16, + &value + ); + } + + /// + /// Prometeのを、Vulkanのに変換します。 + /// + private static PrimitiveTopology ToVulkanTopology(ShapeType type) + { + return type switch + { + ShapeType.Pixel => PrimitiveTopology.PointList, + ShapeType.Line => PrimitiveTopology.LineList, + ShapeType.Rect => PrimitiveTopology.TriangleList, + ShapeType.Triangle => PrimitiveTopology.TriangleList, + ShapeType.Polygon => PrimitiveTopology.TriangleStrip, + _ => throw new ArgumentException(null, nameof(type)), + }; + } +} diff --git a/Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawTextureBatchedCommandRunner.cs b/Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawTextureBatchedCommandRunner.cs new file mode 100644 index 0000000..ccbcd1c --- /dev/null +++ b/Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawTextureBatchedCommandRunner.cs @@ -0,0 +1,236 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using Promete.Graphics.Rendering.Commands; +using Promete.Internal; +using Silk.NET.Vulkan; +using Buffer = Silk.NET.Vulkan.Buffer; + +namespace Promete.Graphics.Rendering.Vulkan.Runners; + +/// +/// をインスタンシングで描画するランナーです。 +/// +internal sealed unsafe class VulkanDrawTextureBatchedCommandRunner + : CommandRunner, + IDisposable +{ + private const int InitialInstanceCapacity = 512; + + // per-instance: mat4(16) + vec4 tintColor(4) + vec4 uvRect(4) = 24 floats + private const int InstanceStride = 24; + + private readonly VulkanContext _ctx; + private readonly VulkanResourceManager _resources; + private readonly VulkanPipelineProvider _pipelines; + + private float[] _instanceData = new float[InitialInstanceCapacity * InstanceStride]; + private Buffer _quadVbo; + private DeviceMemory _quadVboMemory; + private Buffer _quadEbo; + private DeviceMemory _quadEboMemory; + private bool _initialized; + private bool _materialWarned; + private bool _disposed; + + public VulkanDrawTextureBatchedCommandRunner( + VulkanContext ctx, + VulkanResourceManager resources, + VulkanPipelineProvider pipelines + ) + { + _ctx = ctx; + _resources = resources; + _pipelines = pipelines; + } + + public override void Execute(DrawTextureBatchedCommand command) + { + DrawInstanced(command.Items, command.Material); + } + + public void Dispose() + { + if (_disposed || !_initialized) + return; + _disposed = true; + var vk = _ctx.Vk; + vk.DestroyBuffer(_ctx.Device, _quadVbo, null); + vk.FreeMemory(_ctx.Device, _quadVboMemory, null); + vk.DestroyBuffer(_ctx.Device, _quadEbo, null); + vk.FreeMemory(_ctx.Device, _quadEboMemory, null); + } + + private void DrawInstanced(List items, Material? material) + { + if (items.Count == 0 || !_ctx.IsFrameActive) + return; + PrometeApp.Current.ThrowIfNotMainThread(); + + var textureId = items[0].Texture.Handle; + if (!_resources.Contains(textureId)) + return; + + // TODO: Phase 3+ でカスタムマテリアルに対応する + if (material is not null && !_materialWarned) + { + _materialWarned = true; + LogHelper.Bug("Vulkan バックエンドはまだカスタムマテリアルをサポートしていません。"); + } + + EnsureInitialized(); + + var count = items.Count; + EnsureInstanceCapacity(count); + + // per-instanceデータを構築 + for (var i = 0; i < count; i++) + { + var cmd = items[i]; + var model = + Matrix4x4.CreateScale(cmd.Width, cmd.Height, 1) + * Matrix4x4.CreateTranslation(cmd.Pivot.X, cmd.Pivot.Y, 0) + * cmd.ModelMatrix; + + var offset = i * InstanceStride; + _instanceData[offset + 0] = model.M11; + _instanceData[offset + 1] = model.M12; + _instanceData[offset + 2] = model.M13; + _instanceData[offset + 3] = model.M14; + _instanceData[offset + 4] = model.M21; + _instanceData[offset + 5] = model.M22; + _instanceData[offset + 6] = model.M23; + _instanceData[offset + 7] = model.M24; + _instanceData[offset + 8] = model.M31; + _instanceData[offset + 9] = model.M32; + _instanceData[offset + 10] = model.M33; + _instanceData[offset + 11] = model.M34; + _instanceData[offset + 12] = model.M41; + _instanceData[offset + 13] = model.M42; + _instanceData[offset + 14] = model.M43; + _instanceData[offset + 15] = model.M44; + var c = cmd.TintColor; + _instanceData[offset + 16] = c.R / 255f; + _instanceData[offset + 17] = c.G / 255f; + _instanceData[offset + 18] = c.B / 255f; + _instanceData[offset + 19] = c.A / 255f; + + var uvStart = cmd.Texture.UvStart; + var uvEnd = cmd.Texture.UvEnd; + _instanceData[offset + 20] = uvStart.X; + _instanceData[offset + 21] = uvStart.Y; + _instanceData[offset + 22] = uvEnd.X; + _instanceData[offset + 23] = uvEnd.Y; + } + + var (instanceBuffer, instanceOffset) = _ctx.CurrentArena.Push( + new ReadOnlySpan(_instanceData, 0, count * InstanceStride) + ); + + var vk = _ctx.Vk; + var cmdBuffer = _ctx.CurrentCommandBuffer; + + var pipeline = _pipelines.GetTexturePipeline(VulkanPipelineProvider.PassClass.Offscreen); + vk.CmdBindPipeline(cmdBuffer, PipelineBindPoint.Graphics, pipeline); + + // プロジェクション行列 (Vulkan は NDC が Y 下向きなので bottom=0, top=height) + var extent = _ctx.CurrentTargetExtent; + var projection = Matrix4x4.CreateOrthographicOffCenter( + 0, + extent.Width, + 0, + extent.Height, + -1f, + 1f + ); + vk.CmdPushConstants( + cmdBuffer, + _pipelines.TextureLayout, + ShaderStageFlags.VertexBit, + 0, + 64, + &projection + ); + + var descriptorSet = _resources.GetDescriptorSet(textureId); + vk.CmdBindDescriptorSets( + cmdBuffer, + PipelineBindPoint.Graphics, + _pipelines.TextureLayout, + 0, + 1, + in descriptorSet, + 0, + null + ); + + var vertexBuffers = stackalloc Buffer[2] { _quadVbo, instanceBuffer }; + var offsets = stackalloc ulong[2] { 0, instanceOffset }; + vk.CmdBindVertexBuffers(cmdBuffer, 0, 2, vertexBuffers, offsets); + vk.CmdBindIndexBuffer(cmdBuffer, _quadEbo, 0, IndexType.Uint32); + vk.CmdDrawIndexed(cmdBuffer, 6, (uint)count, 0, 0, 0); + } + + private void EnsureInitialized() + { + if (_initialized) + return; + + // 単位クワッドの頂点データ(位置 + UV) + Span vertices = + [ + 1.0f, 0.0f, 1.0f, 0.0f, // 右下 + 1.0f, 1.0f, 1.0f, 1.0f, // 右上 + 0.0f, 1.0f, 0.0f, 1.0f, // 左上 + 0.0f, 0.0f, 0.0f, 0.0f, // 左下 + ]; + Span indices = [0, 1, 3, 1, 2, 3]; + + (_quadVbo, _quadVboMemory) = CreateStaticBuffer( + vertices, + BufferUsageFlags.VertexBufferBit + ); + (_quadEbo, _quadEboMemory) = CreateStaticBuffer( + indices, + BufferUsageFlags.IndexBufferBit + ); + + _initialized = true; + } + + private (Buffer Buffer, DeviceMemory Memory) CreateStaticBuffer( + ReadOnlySpan data, + BufferUsageFlags usage + ) + where T : unmanaged + { + var size = (ulong)(data.Length * sizeof(T)); + var (buffer, memory) = _ctx.CreateBuffer( + size, + usage, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit + ); + + void* mapped; + _ctx.Vk.MapMemory(_ctx.Device, memory, 0, size, 0, &mapped); + fixed (T* src = data) + { + System.Buffer.MemoryCopy(src, mapped, size, size); + } + + _ctx.Vk.UnmapMemory(_ctx.Device, memory); + return (buffer, memory); + } + + private void EnsureInstanceCapacity(int count) + { + var required = count * InstanceStride; + if (_instanceData.Length >= required) + return; + + var newSize = _instanceData.Length; + while (newSize < required) + newSize *= 2; + _instanceData = new float[newSize]; + } +} diff --git a/Promete/Graphics/Rendering/Vulkan/Runners/VulkanTrimCommandRunners.cs b/Promete/Graphics/Rendering/Vulkan/Runners/VulkanTrimCommandRunners.cs new file mode 100644 index 0000000..cdaaf3f --- /dev/null +++ b/Promete/Graphics/Rendering/Vulkan/Runners/VulkanTrimCommandRunners.cs @@ -0,0 +1,51 @@ +using System; +using Promete.Graphics.Rendering.Commands; +using Silk.NET.Vulkan; + +namespace Promete.Graphics.Rendering.Vulkan.Runners; + +/// +/// でシザー領域を設定するランナーです。 +/// コマンドの座標は左上原点のため、Vulkan ではそのまま使用できます。 +/// +internal sealed class VulkanBeginTrimCommandRunner(VulkanContext ctx) + : CommandRunner +{ + public override void Execute(BeginTrimCommand command) + { + if (!ctx.IsFrameActive) + return; + ctx.SetTrimScissor(ToScissor(command.X, command.Y, command.Width, command.Height)); + } + + internal static Rect2D ToScissor(int x, int y, int width, int height) + { + return new Rect2D( + new Offset2D(Math.Max(0, x), Math.Max(0, y)), + new Extent2D((uint)Math.Max(0, width), (uint)Math.Max(0, height)) + ); + } +} + +/// +/// でシザー領域を復元するランナーです。 +/// +internal sealed class VulkanEndTrimCommandRunner(VulkanContext ctx) : CommandRunner +{ + public override void Execute(EndTrimCommand command) + { + if (!ctx.IsFrameActive) + return; + + ctx.SetTrimScissor( + command.WasEnabled + ? VulkanBeginTrimCommandRunner.ToScissor( + command.X, + command.Y, + command.Width, + command.Height + ) + : null + ); + } +} diff --git a/Promete/Graphics/Rendering/Vulkan/VulkanContext.cs b/Promete/Graphics/Rendering/Vulkan/VulkanContext.cs index 8073cd4..1e3af78 100644 --- a/Promete/Graphics/Rendering/Vulkan/VulkanContext.cs +++ b/Promete/Graphics/Rendering/Vulkan/VulkanContext.cs @@ -6,19 +6,27 @@ using Silk.NET.Vulkan; using Silk.NET.Vulkan.Extensions.KHR; using Silk.NET.Windowing; +using Buffer = Silk.NET.Vulkan.Buffer; using Semaphore = Silk.NET.Vulkan.Semaphore; namespace Promete.Graphics.Rendering.Vulkan; /// /// Vulkan のインスタンス・デバイス・スワップチェーン・フレーム同期を管理するコンテキストです。 -/// Vulkan バックエンドの中核となる低レベルオブジェクトを保持します。 +/// フレームのライフサイクル(コマンドバッファ記録・サブミット・プレゼント)と、 +/// オフスクリーンレンダーターゲットのスタック管理も担います。 /// internal sealed unsafe class VulkanContext : IDisposable { - private const int MaxFramesInFlight = 2; + /// 同時進行フレーム数。 + public const int FramesInFlight = 2; + + /// オフスクリーンレンダーターゲットのカラーフォーマット。 + public const Format OffscreenFormat = Format.R8G8B8A8Unorm; private readonly IWindow _window; + private readonly Stack _targetStack = new(); + private readonly List[] _deferredDestroys = new List[FramesInFlight]; private KhrSurface _khrSurface = null!; private KhrSwapchain _khrSwapchain = null!; @@ -29,6 +37,7 @@ internal sealed unsafe class VulkanContext : IDisposable private Device _device; private uint _queueFamilyIndex; private Queue _graphicsQueue; + private PhysicalDeviceMemoryProperties _memoryProperties; private SwapchainKHR _swapchain; private Format _swapchainFormat; @@ -37,15 +46,23 @@ internal sealed unsafe class VulkanContext : IDisposable private ImageView[] _swapchainImageViews = []; private Framebuffer[] _framebuffers = []; - private RenderPass _renderPass; + private RenderPass _swapchainPass; + private RenderPass _offscreenClearPass; + private RenderPass _offscreenLoadPass; private CommandPool _commandPool; + private CommandPool _transientPool; private CommandBuffer[] _commandBuffers = []; + private VulkanFrameArena[] _arenas = []; private Semaphore[] _imageAvailableSemaphores = []; private Semaphore[] _renderFinishedSemaphores = []; private Fence[] _inFlightFences = []; private int _currentFrame; + private uint _currentImageIndex; + private bool _frameActive; + private bool _swapchainPassActive; + private bool _swapchainPassDone; private bool _framebufferResized; private bool _disposed; @@ -53,6 +70,8 @@ public VulkanContext(IWindow window) { _window = window; _window.FramebufferResize += _ => _framebufferResized = true; + for (var i = 0; i < FramesInFlight; i++) + _deferredDestroys[i] = []; } /// Vulkan API のエントリポイントを取得します。 @@ -70,6 +89,40 @@ public VulkanContext(IWindow window) /// スワップチェーンのフォーマットを取得します。 public Format SwapchainFormat => _swapchainFormat; + /// スワップチェーンの大きさを取得します。 + public Extent2D SwapchainExtent => _swapchainExtent; + + /// 初期化済みかどうかを取得します。 + public bool IsInitialized { get; private set; } + + /// フレームが記録中かどうかを取得します。 + public bool IsFrameActive => _frameActive; + + /// 現在のフレームスロット (0..FramesInFlight-1) を取得します。 + public int FrameIndex => _currentFrame; + + /// 現在記録中のコマンドバッファを取得します。 + public CommandBuffer CurrentCommandBuffer => _commandBuffers[_currentFrame]; + + /// 現在のフレームで使用する動的頂点データアリーナを取得します。 + public VulkanFrameArena CurrentArena => _arenas[_currentFrame]; + + /// オフスクリーン描画用 (クリア) レンダーパスを取得します。 + public RenderPass OffscreenClearPass => _offscreenClearPass; + + /// オフスクリーン描画用 (ロード) レンダーパスを取得します。 + public RenderPass OffscreenLoadPass => _offscreenLoadPass; + + /// スワップチェーン描画用レンダーパスを取得します。 + public RenderPass SwapchainPass => _swapchainPass; + + /// 現在のトリム (シザー) 領域。null なら全域。 + public Rect2D? TrimScissor { get; private set; } + + /// 現在の描画ターゲットの大きさを取得します。 + public Extent2D CurrentTargetExtent => + _targetStack.Count > 0 ? _targetStack.Peek().Extent : _swapchainExtent; + /// /// Vulkan オブジェクトを初期化します。ウィンドウのロード後(サーフェスが取得可能になった後)に呼び出してください。 /// @@ -81,42 +134,52 @@ public void Initialize(string appName) CreateLogicalDevice(); CreateSwapchain(); CreateImageViews(); - CreateRenderPass(); + CreateRenderPasses(); CreateFramebuffers(); - CreateCommandPool(); + CreateCommandPools(); CreateCommandBuffers(); CreateSyncObjects(); + + _arenas = new VulkanFrameArena[FramesInFlight]; + for (var i = 0; i < FramesInFlight; i++) + _arenas[i] = new VulkanFrameArena(this); + + IsInitialized = true; } + // --- フレームライフサイクル --- + /// - /// 1 フレームを描画します。現状はクリアカラーで塗りつぶすのみです。 - /// TODO: Phase 3 でレンダリングコマンドキューの実行結果をここに統合する。 + /// フレームの記録を開始します。スワップチェーンイメージの取得とコマンドバッファの開始を行います。 /// - public void DrawFrame(Color clearColor) + /// フレームを開始できた場合 true。最小化中などで描画をスキップする場合 false。 + public bool BeginFrame() { - // 最小化中などフレームバッファサイズが 0 の間は描画しない var fb = _window.FramebufferSize; if (fb.X <= 0 || fb.Y <= 0) - return; + return false; var vk = Vk; var fence = _inFlightFences[_currentFrame]; vk.WaitForFences(_device, 1, in fence, true, ulong.MaxValue); - uint imageIndex = 0; + // このスロットの前回フレームが完了したので、遅延破棄を実行 + FlushDeferredDestroys(_currentFrame); + _arenas[_currentFrame].Reset(); + var result = _khrSwapchain.AcquireNextImage( _device, _swapchain, ulong.MaxValue, _imageAvailableSemaphores[_currentFrame], default, - ref imageIndex + ref _currentImageIndex ); if (result == Result.ErrorOutOfDateKhr) { RecreateSwapchain(); - return; + return false; } if (result != Result.Success && result != Result.SuboptimalKhr) @@ -126,7 +189,37 @@ ref imageIndex var cmd = _commandBuffers[_currentFrame]; vk.ResetCommandBuffer(cmd, 0); - RecordCommandBuffer(cmd, imageIndex, clearColor); + var beginInfo = new CommandBufferBeginInfo { SType = StructureType.CommandBufferBeginInfo }; + ThrowIfFailed(vk.BeginCommandBuffer(cmd, in beginInfo), "コマンドバッファの記録開始"); + + _frameActive = true; + _swapchainPassActive = false; + _swapchainPassDone = false; + TrimScissor = null; + return true; + } + + /// + /// フレームの記録を終了し、サブミット・プレゼントします。 + /// + public void EndFrame() + { + if (!_frameActive) + return; + + var vk = Vk; + var cmd = _commandBuffers[_currentFrame]; + + // ブリットが行われなかった場合でも、スワップチェーンイメージをプレゼント可能な状態にする + if (!_swapchainPassDone) + { + BeginSwapchainPass(Color.Black); + EndSwapchainPass(); + } + + ThrowIfFailed(vk.EndCommandBuffer(cmd), "コマンドバッファの記録終了"); + _frameActive = false; + _targetStack.Clear(); var waitSemaphore = _imageAvailableSemaphores[_currentFrame]; var signalSemaphore = _renderFinishedSemaphores[_currentFrame]; @@ -144,9 +237,13 @@ ref imageIndex PSignalSemaphores = &signalSemaphore, }; - ThrowIfFailed(vk.QueueSubmit(_graphicsQueue, 1, in submitInfo, fence), "キューの送信"); + ThrowIfFailed( + vk.QueueSubmit(_graphicsQueue, 1, in submitInfo, _inFlightFences[_currentFrame]), + "キューの送信" + ); var swapchain = _swapchain; + var imageIndex = _currentImageIndex; var presentInfo = new PresentInfoKHR { SType = StructureType.PresentInfoKhr, @@ -157,7 +254,7 @@ ref imageIndex PImageIndices = &imageIndex, }; - result = _khrSwapchain.QueuePresent(_graphicsQueue, in presentInfo); + var result = _khrSwapchain.QueuePresent(_graphicsQueue, in presentInfo); if (result is Result.ErrorOutOfDateKhr or Result.SuboptimalKhr || _framebufferResized) { @@ -169,7 +266,373 @@ ref imageIndex throw new InvalidOperationException($"プレゼントに失敗しました: {result}"); } - _currentFrame = (_currentFrame + 1) % MaxFramesInFlight; + _currentFrame = (_currentFrame + 1) % FramesInFlight; + } + + // --- レンダーターゲットスタック --- + + /// + /// レンダーターゲットをスタックに積み、そのターゲットへのレンダーパスを開始します。 + /// 既にパスが記録中の場合は中断し、Pop 時に再開します。 + /// + public void PushRenderTarget(VulkanRenderTarget target, Color? clearColor) + { + EnsureFrameActive(); + var cmd = CurrentCommandBuffer; + + if (_targetStack.Count > 0) + Vk.CmdEndRenderPass(cmd); + + _targetStack.Push(target); + BeginOffscreenPass(target, clearColor); + } + + /// + /// レンダーターゲットをスタックから降ろし、前のターゲットへのレンダーパスを再開します。 + /// + public void PopRenderTarget() + { + EnsureFrameActive(); + var cmd = CurrentCommandBuffer; + + Vk.CmdEndRenderPass(cmd); + _targetStack.Pop(); + + if (_targetStack.Count > 0) + BeginOffscreenPass(_targetStack.Peek(), null); + } + + /// + /// スワップチェーンイメージへのレンダーパスを開始します。(画面ブリット用) + /// + public void BeginSwapchainPass(Color clearColor) + { + EnsureFrameActive(); + if (_targetStack.Count > 0) + throw new InvalidOperationException( + "レンダーターゲットのキャプチャ中はスワップチェーンパスを開始できません。" + ); + + var cmd = CurrentCommandBuffer; + var clearValue = new ClearValue(ToClearColor(clearColor)); + var beginInfo = new RenderPassBeginInfo + { + SType = StructureType.RenderPassBeginInfo, + RenderPass = _swapchainPass, + Framebuffer = _framebuffers[_currentImageIndex], + RenderArea = new Rect2D(new Offset2D(0, 0), _swapchainExtent), + ClearValueCount = 1, + PClearValues = &clearValue, + }; + + Vk.CmdBeginRenderPass(cmd, in beginInfo, SubpassContents.Inline); + ApplyViewportAndScissor(_swapchainExtent, ignoreTrim: true); + _swapchainPassActive = true; + _swapchainPassDone = true; + } + + /// + /// スワップチェーンイメージへのレンダーパスを終了します。 + /// + public void EndSwapchainPass() + { + if (!_swapchainPassActive) + return; + Vk.CmdEndRenderPass(CurrentCommandBuffer); + _swapchainPassActive = false; + } + + /// + /// トリム (シザー) 領域を設定します。null で全域に戻します。 + /// + public void SetTrimScissor(Rect2D? scissor) + { + TrimScissor = scissor; + ApplyCurrentScissor(); + } + + // --- リソースヘルパー --- + + /// + /// バッファとそのメモリを作成します。 + /// + public (Buffer Buffer, DeviceMemory Memory) CreateBuffer( + ulong size, + BufferUsageFlags usage, + MemoryPropertyFlags properties + ) + { + var vk = Vk; + var createInfo = new BufferCreateInfo + { + SType = StructureType.BufferCreateInfo, + Size = size, + Usage = usage, + SharingMode = SharingMode.Exclusive, + }; + ThrowIfFailed(vk.CreateBuffer(_device, in createInfo, null, out var buffer), "バッファの作成"); + + vk.GetBufferMemoryRequirements(_device, buffer, out var requirements); + var allocInfo = new MemoryAllocateInfo + { + SType = StructureType.MemoryAllocateInfo, + AllocationSize = requirements.Size, + MemoryTypeIndex = FindMemoryType(requirements.MemoryTypeBits, properties), + }; + ThrowIfFailed(vk.AllocateMemory(_device, in allocInfo, null, out var memory), "メモリの確保"); + vk.BindBufferMemory(_device, buffer, memory, 0); + return (buffer, memory); + } + + /// + /// 2D イメージとそのメモリを作成します。 + /// + public (Image Image, DeviceMemory Memory) CreateImage2D( + uint width, + uint height, + Format format, + ImageUsageFlags usage + ) + { + var vk = Vk; + var createInfo = new ImageCreateInfo + { + SType = StructureType.ImageCreateInfo, + ImageType = ImageType.Type2D, + Format = format, + Extent = new Extent3D(width, height, 1), + MipLevels = 1, + ArrayLayers = 1, + Samples = SampleCountFlags.Count1Bit, + Tiling = ImageTiling.Optimal, + Usage = usage, + SharingMode = SharingMode.Exclusive, + InitialLayout = ImageLayout.Undefined, + }; + ThrowIfFailed(vk.CreateImage(_device, in createInfo, null, out var image), "イメージの作成"); + + vk.GetImageMemoryRequirements(_device, image, out var requirements); + var allocInfo = new MemoryAllocateInfo + { + SType = StructureType.MemoryAllocateInfo, + AllocationSize = requirements.Size, + MemoryTypeIndex = FindMemoryType( + requirements.MemoryTypeBits, + MemoryPropertyFlags.DeviceLocalBit + ), + }; + ThrowIfFailed(vk.AllocateMemory(_device, in allocInfo, null, out var memory), "メモリの確保"); + vk.BindImageMemory(_device, image, memory, 0); + return (image, memory); + } + + /// + /// 2D イメージビューを作成します。 + /// + public ImageView CreateImageView2D(Image image, Format format) + { + var createInfo = new ImageViewCreateInfo + { + SType = StructureType.ImageViewCreateInfo, + Image = image, + ViewType = ImageViewType.Type2D, + Format = format, + SubresourceRange = new ImageSubresourceRange(ImageAspectFlags.ColorBit, 0, 1, 0, 1), + }; + ThrowIfFailed( + Vk.CreateImageView(_device, in createInfo, null, out var view), + "イメージビューの作成" + ); + return view; + } + + /// + /// オフスクリーンパス用のフレームバッファを作成します。 + /// + public Framebuffer CreateOffscreenFramebuffer(ImageView view, uint width, uint height) + { + var createInfo = new FramebufferCreateInfo + { + SType = StructureType.FramebufferCreateInfo, + RenderPass = _offscreenClearPass, + AttachmentCount = 1, + PAttachments = &view, + Width = width, + Height = height, + Layers = 1, + }; + ThrowIfFailed( + Vk.CreateFramebuffer(_device, in createInfo, null, out var framebuffer), + "フレームバッファの作成" + ); + return framebuffer; + } + + /// + /// 一時的なコマンドバッファでコマンドを実行し、完了まで待機します。(リソース転送用) + /// + public void ExecuteOneTime(Action record) + { + var vk = Vk; + var allocInfo = new CommandBufferAllocateInfo + { + SType = StructureType.CommandBufferAllocateInfo, + CommandPool = _transientPool, + Level = CommandBufferLevel.Primary, + CommandBufferCount = 1, + }; + vk.AllocateCommandBuffers(_device, in allocInfo, out var cmd); + + var beginInfo = new CommandBufferBeginInfo + { + SType = StructureType.CommandBufferBeginInfo, + Flags = CommandBufferUsageFlags.OneTimeSubmitBit, + }; + vk.BeginCommandBuffer(cmd, in beginInfo); + record(cmd); + vk.EndCommandBuffer(cmd); + + var submitInfo = new SubmitInfo + { + SType = StructureType.SubmitInfo, + CommandBufferCount = 1, + PCommandBuffers = &cmd, + }; + ThrowIfFailed(vk.QueueSubmit(_graphicsQueue, 1, in submitInfo, default), "転送コマンドの送信"); + vk.QueueWaitIdle(_graphicsQueue); + vk.FreeCommandBuffers(_device, _transientPool, 1, in cmd); + } + + /// + /// イメージのレイアウトを遷移します。 + /// + public void TransitionImageLayout( + CommandBuffer cmd, + Image image, + ImageLayout oldLayout, + ImageLayout newLayout, + PipelineStageFlags srcStage, + AccessFlags srcAccess, + PipelineStageFlags dstStage, + AccessFlags dstAccess + ) + { + var barrier = new ImageMemoryBarrier + { + SType = StructureType.ImageMemoryBarrier, + OldLayout = oldLayout, + NewLayout = newLayout, + SrcQueueFamilyIndex = Vk.QueueFamilyIgnored, + DstQueueFamilyIndex = Vk.QueueFamilyIgnored, + Image = image, + SubresourceRange = new ImageSubresourceRange(ImageAspectFlags.ColorBit, 0, 1, 0, 1), + SrcAccessMask = srcAccess, + DstAccessMask = dstAccess, + }; + Vk.CmdPipelineBarrier( + cmd, + srcStage, + dstStage, + 0, + 0, + null, + 0, + null, + 1, + in barrier + ); + } + + /// + /// イメージのピクセルを RGBA8 のバイト列として読み出します。(スクリーンショット用) + /// 完了まで待機するため低速です。 + /// + public byte[] ReadImagePixels(Image image, uint width, uint height) + { + var vk = Vk; + var size = (ulong)(width * height * 4); + + var (staging, stagingMemory) = CreateBuffer( + size, + BufferUsageFlags.TransferDstBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit + ); + + ExecuteOneTime(cmd => + { + TransitionImageLayout( + cmd, + image, + ImageLayout.General, + ImageLayout.General, + PipelineStageFlags.ColorAttachmentOutputBit, + AccessFlags.ColorAttachmentWriteBit, + PipelineStageFlags.TransferBit, + AccessFlags.TransferReadBit + ); + + var region = new BufferImageCopy + { + BufferOffset = 0, + BufferRowLength = 0, + BufferImageHeight = 0, + ImageSubresource = new ImageSubresourceLayers(ImageAspectFlags.ColorBit, 0, 0, 1), + ImageOffset = new Offset3D(0, 0, 0), + ImageExtent = new Extent3D(width, height, 1), + }; + Vk.CmdCopyImageToBuffer(cmd, image, ImageLayout.General, staging, 1, in region); + }); + + var pixels = new byte[size]; + void* mapped; + vk.MapMemory(_device, stagingMemory, 0, size, 0, &mapped); + fixed (byte* dst = pixels) + { + System.Buffer.MemoryCopy(mapped, dst, size, size); + } + + vk.UnmapMemory(_device, stagingMemory); + vk.DestroyBuffer(_device, staging, null); + vk.FreeMemory(_device, stagingMemory, null); + return pixels; + } + + /// + /// 現在のフレームスロットの実行完了後にリソースを破棄するアクションを登録します。 + /// + public void DeferDestroy(Action destroy) + { + if (!IsInitialized) + { + destroy(); + return; + } + + _deferredDestroys[_currentFrame].Add(destroy); + } + + /// + /// デバイスの全処理完了を待機します。 + /// + public void WaitIdle() + { + Vk.DeviceWaitIdle(_device); + } + + /// + /// メモリタイプを検索します。 + /// + public uint FindMemoryType(uint typeBits, MemoryPropertyFlags properties) + { + for (var i = 0u; i < _memoryProperties.MemoryTypeCount; i++) + { + if ((typeBits & (1u << (int)i)) == 0) + continue; + if ((_memoryProperties.MemoryTypes[(int)i].PropertyFlags & properties) == properties) + return i; + } + + throw new InvalidOperationException("適切なメモリタイプが見つかりませんでした。"); } public void Dispose() @@ -181,9 +644,15 @@ public void Dispose() var vk = Vk; vk.DeviceWaitIdle(_device); + for (var i = 0; i < FramesInFlight; i++) + FlushDeferredDestroys(i); + + foreach (var arena in _arenas) + arena.Dispose(); + CleanupSwapchain(); - for (var i = 0; i < MaxFramesInFlight; i++) + for (var i = 0; i < FramesInFlight; i++) { vk.DestroySemaphore(_device, _imageAvailableSemaphores[i], null); vk.DestroySemaphore(_device, _renderFinishedSemaphores[i], null); @@ -191,7 +660,10 @@ public void Dispose() } vk.DestroyCommandPool(_device, _commandPool, null); - vk.DestroyRenderPass(_device, _renderPass, null); + vk.DestroyCommandPool(_device, _transientPool, null); + vk.DestroyRenderPass(_device, _swapchainPass, null); + vk.DestroyRenderPass(_device, _offscreenClearPass, null); + vk.DestroyRenderPass(_device, _offscreenLoadPass, null); vk.DestroyDevice(_device, null); _khrSurface.DestroySurface(_instance, _surface, null); vk.DestroyInstance(_instance, null); @@ -201,13 +673,84 @@ public void Dispose() vk.Dispose(); } + private static ClearColorValue ToClearColor(Color c) => + new(c.R / 255f, c.G / 255f, c.B / 255f, c.A / 255f); + private static void ThrowIfFailed(Result result, string operation) { if (result != Result.Success) throw new InvalidOperationException($"{operation}に失敗しました: {result}"); } - // --- 初期化 --- + // --- private: フレーム内部処理 --- + private void EnsureFrameActive() + { + if (!_frameActive) + throw new InvalidOperationException("フレームの記録が開始されていません。"); + } + + private void BeginOffscreenPass(VulkanRenderTarget target, Color? clearColor) + { + var cmd = CurrentCommandBuffer; + var clearValue = new ClearValue(ToClearColor(clearColor ?? Color.Transparent)); + var beginInfo = new RenderPassBeginInfo + { + SType = StructureType.RenderPassBeginInfo, + RenderPass = clearColor.HasValue ? _offscreenClearPass : _offscreenLoadPass, + Framebuffer = target.Framebuffer, + RenderArea = new Rect2D(new Offset2D(0, 0), target.Extent), + ClearValueCount = 1, + PClearValues = &clearValue, + }; + + Vk.CmdBeginRenderPass(cmd, in beginInfo, SubpassContents.Inline); + ApplyViewportAndScissor(target.Extent, ignoreTrim: false); + } + + private void ApplyViewportAndScissor(Extent2D extent, bool ignoreTrim) + { + var cmd = CurrentCommandBuffer; + var viewport = new Viewport(0, 0, extent.Width, extent.Height, 0f, 1f); + Vk.CmdSetViewport(cmd, 0, 1, in viewport); + + if (ignoreTrim || TrimScissor is not { } trim) + { + var full = new Rect2D(new Offset2D(0, 0), extent); + Vk.CmdSetScissor(cmd, 0, 1, in full); + } + else + { + Vk.CmdSetScissor(cmd, 0, 1, in trim); + } + } + + private void ApplyCurrentScissor() + { + if (!_frameActive) + return; + var cmd = CurrentCommandBuffer; + if (TrimScissor is { } trim) + { + Vk.CmdSetScissor(cmd, 0, 1, in trim); + } + else + { + var full = new Rect2D(new Offset2D(0, 0), CurrentTargetExtent); + Vk.CmdSetScissor(cmd, 0, 1, in full); + } + } + + private void FlushDeferredDestroys(int slot) + { + var list = _deferredDestroys[slot]; + if (list.Count == 0) + return; + foreach (var destroy in list) + destroy(); + list.Clear(); + } + + // --- private: 初期化 --- private void CreateInstance(string appName) { var vk = Vk; @@ -316,20 +859,27 @@ private void PickPhysicalDevice() vk.GetPhysicalDeviceProperties(device, out var props); if (props.DeviceType == PhysicalDeviceType.DiscreteGpu) { - _physicalDevice = device; - _queueFamilyIndex = queueFamily; + SelectPhysicalDevice(device, queueFamily); return; } - fallback ??= device; - if (fallback.Value.Handle == device.Handle) + if (fallback is null) + { + fallback = device; fallbackQueueFamily = queueFamily; + } } - _physicalDevice = - fallback - ?? throw new NotSupportedException("要件を満たす Vulkan デバイスが見つかりませんでした。"); - _queueFamilyIndex = fallbackQueueFamily; + if (fallback is null) + throw new NotSupportedException("要件を満たす Vulkan デバイスが見つかりませんでした。"); + SelectPhysicalDevice(fallback.Value, fallbackQueueFamily); + } + + private void SelectPhysicalDevice(PhysicalDevice device, uint queueFamily) + { + _physicalDevice = device; + _queueFamilyIndex = queueFamily; + Vk.GetPhysicalDeviceMemoryProperties(device, out _memoryProperties); } private bool TryFindQueueFamily(PhysicalDevice device, out uint queueFamilyIndex) @@ -521,41 +1071,54 @@ private Extent2D ChooseExtent(SurfaceCapabilitiesKHR caps) private void CreateImageViews() { - var vk = Vk; _swapchainImageViews = new ImageView[_swapchainImages.Length]; - for (var i = 0; i < _swapchainImages.Length; i++) - { - var createInfo = new ImageViewCreateInfo - { - SType = StructureType.ImageViewCreateInfo, - Image = _swapchainImages[i], - ViewType = ImageViewType.Type2D, - Format = _swapchainFormat, - SubresourceRange = new ImageSubresourceRange(ImageAspectFlags.ColorBit, 0, 1, 0, 1), - }; - - ThrowIfFailed( - vk.CreateImageView(_device, in createInfo, null, out _swapchainImageViews[i]), - "イメージビューの作成" - ); - } + _swapchainImageViews[i] = CreateImageView2D(_swapchainImages[i], _swapchainFormat); } - private void CreateRenderPass() + private void CreateRenderPasses() { - var vk = Vk; + _swapchainPass = CreateRenderPass( + _swapchainFormat, + AttachmentLoadOp.Clear, + ImageLayout.Undefined, + ImageLayout.PresentSrcKhr, + forSampling: false + ); + _offscreenClearPass = CreateRenderPass( + OffscreenFormat, + AttachmentLoadOp.Clear, + ImageLayout.General, + ImageLayout.General, + forSampling: true + ); + _offscreenLoadPass = CreateRenderPass( + OffscreenFormat, + AttachmentLoadOp.Load, + ImageLayout.General, + ImageLayout.General, + forSampling: true + ); + } + private RenderPass CreateRenderPass( + Format format, + AttachmentLoadOp loadOp, + ImageLayout initialLayout, + ImageLayout finalLayout, + bool forSampling + ) + { var colorAttachment = new AttachmentDescription { - Format = _swapchainFormat, + Format = format, Samples = SampleCountFlags.Count1Bit, - LoadOp = AttachmentLoadOp.Clear, + LoadOp = loadOp, StoreOp = AttachmentStoreOp.Store, StencilLoadOp = AttachmentLoadOp.DontCare, StencilStoreOp = AttachmentStoreOp.DontCare, - InitialLayout = ImageLayout.Undefined, - FinalLayout = ImageLayout.PresentSrcKhr, + InitialLayout = initialLayout, + FinalLayout = finalLayout, }; var colorRef = new AttachmentReference(0, ImageLayout.ColorAttachmentOptimal); @@ -567,14 +1130,26 @@ private void CreateRenderPass() PColorAttachments = &colorRef, }; - var dependency = new SubpassDependency + // 開始依存: 前段の描画/読み取り完了を待つ + // 終了依存 (forSampling): パス完了後のフラグメントシェーダーからの読み取りを同期する + var dependencies = stackalloc SubpassDependency[2]; + dependencies[0] = new SubpassDependency { SrcSubpass = Vk.SubpassExternal, DstSubpass = 0, - SrcStageMask = PipelineStageFlags.ColorAttachmentOutputBit, - SrcAccessMask = 0, + SrcStageMask = PipelineStageFlags.ColorAttachmentOutputBit | PipelineStageFlags.FragmentShaderBit, + SrcAccessMask = AccessFlags.ColorAttachmentWriteBit | AccessFlags.ShaderReadBit, DstStageMask = PipelineStageFlags.ColorAttachmentOutputBit, - DstAccessMask = AccessFlags.ColorAttachmentWriteBit, + DstAccessMask = AccessFlags.ColorAttachmentWriteBit | AccessFlags.ColorAttachmentReadBit, + }; + dependencies[1] = new SubpassDependency + { + SrcSubpass = 0, + DstSubpass = Vk.SubpassExternal, + SrcStageMask = PipelineStageFlags.ColorAttachmentOutputBit, + SrcAccessMask = AccessFlags.ColorAttachmentWriteBit, + DstStageMask = PipelineStageFlags.FragmentShaderBit | PipelineStageFlags.TransferBit, + DstAccessMask = AccessFlags.ShaderReadBit | AccessFlags.TransferReadBit, }; var createInfo = new RenderPassCreateInfo @@ -584,19 +1159,19 @@ private void CreateRenderPass() PAttachments = &colorAttachment, SubpassCount = 1, PSubpasses = &subpass, - DependencyCount = 1, - PDependencies = &dependency, + DependencyCount = forSampling ? 2u : 1u, + PDependencies = dependencies, }; ThrowIfFailed( - vk.CreateRenderPass(_device, in createInfo, null, out _renderPass), + Vk.CreateRenderPass(_device, in createInfo, null, out var renderPass), "レンダーパスの作成" ); + return renderPass; } private void CreateFramebuffers() { - var vk = Vk; _framebuffers = new Framebuffer[_swapchainImageViews.Length]; for (var i = 0; i < _swapchainImageViews.Length; i++) @@ -605,7 +1180,7 @@ private void CreateFramebuffers() var createInfo = new FramebufferCreateInfo { SType = StructureType.FramebufferCreateInfo, - RenderPass = _renderPass, + RenderPass = _swapchainPass, AttachmentCount = 1, PAttachments = &attachment, Width = _swapchainExtent.Width, @@ -614,13 +1189,13 @@ private void CreateFramebuffers() }; ThrowIfFailed( - vk.CreateFramebuffer(_device, in createInfo, null, out _framebuffers[i]), + Vk.CreateFramebuffer(_device, in createInfo, null, out _framebuffers[i]), "フレームバッファの作成" ); } } - private void CreateCommandPool() + private void CreateCommandPools() { var createInfo = new CommandPoolCreateInfo { @@ -628,23 +1203,33 @@ private void CreateCommandPool() Flags = CommandPoolCreateFlags.ResetCommandBufferBit, QueueFamilyIndex = _queueFamilyIndex, }; - ThrowIfFailed( Vk.CreateCommandPool(_device, in createInfo, null, out _commandPool), "コマンドプールの作成" ); + + var transientInfo = new CommandPoolCreateInfo + { + SType = StructureType.CommandPoolCreateInfo, + Flags = CommandPoolCreateFlags.TransientBit, + QueueFamilyIndex = _queueFamilyIndex, + }; + ThrowIfFailed( + Vk.CreateCommandPool(_device, in transientInfo, null, out _transientPool), + "コマンドプールの作成" + ); } private void CreateCommandBuffers() { - _commandBuffers = new CommandBuffer[MaxFramesInFlight]; + _commandBuffers = new CommandBuffer[FramesInFlight]; var allocInfo = new CommandBufferAllocateInfo { SType = StructureType.CommandBufferAllocateInfo, CommandPool = _commandPool, Level = CommandBufferLevel.Primary, - CommandBufferCount = MaxFramesInFlight, + CommandBufferCount = FramesInFlight, }; fixed (CommandBuffer* p = _commandBuffers) @@ -656,9 +1241,9 @@ private void CreateCommandBuffers() private void CreateSyncObjects() { var vk = Vk; - _imageAvailableSemaphores = new Semaphore[MaxFramesInFlight]; - _renderFinishedSemaphores = new Semaphore[MaxFramesInFlight]; - _inFlightFences = new Fence[MaxFramesInFlight]; + _imageAvailableSemaphores = new Semaphore[FramesInFlight]; + _renderFinishedSemaphores = new Semaphore[FramesInFlight]; + _inFlightFences = new Fence[FramesInFlight]; var semaphoreInfo = new SemaphoreCreateInfo { SType = StructureType.SemaphoreCreateInfo }; var fenceInfo = new FenceCreateInfo @@ -667,7 +1252,7 @@ private void CreateSyncObjects() Flags = FenceCreateFlags.SignaledBit, }; - for (var i = 0; i < MaxFramesInFlight; i++) + for (var i = 0; i < FramesInFlight; i++) { ThrowIfFailed( vk.CreateSemaphore(_device, in semaphoreInfo, null, out _imageAvailableSemaphores[i]), @@ -684,41 +1269,7 @@ private void CreateSyncObjects() } } - // --- フレーム描画 --- - private void RecordCommandBuffer(CommandBuffer cmd, uint imageIndex, Color clearColor) - { - var vk = Vk; - - var beginInfo = new CommandBufferBeginInfo { SType = StructureType.CommandBufferBeginInfo }; - ThrowIfFailed(vk.BeginCommandBuffer(cmd, in beginInfo), "コマンドバッファの記録開始"); - - var clearValue = new ClearValue( - new ClearColorValue( - clearColor.R / 255f, - clearColor.G / 255f, - clearColor.B / 255f, - clearColor.A / 255f - ) - ); - - var renderPassBegin = new RenderPassBeginInfo - { - SType = StructureType.RenderPassBeginInfo, - RenderPass = _renderPass, - Framebuffer = _framebuffers[imageIndex], - RenderArea = new Rect2D(new Offset2D(0, 0), _swapchainExtent), - ClearValueCount = 1, - PClearValues = &clearValue, - }; - - vk.CmdBeginRenderPass(cmd, in renderPassBegin, SubpassContents.Inline); - - // TODO: Phase 3 でここに描画コマンドを記録する - vk.CmdEndRenderPass(cmd); - ThrowIfFailed(vk.EndCommandBuffer(cmd), "コマンドバッファの記録終了"); - } - - // --- スワップチェーン再構築 --- + // --- private: スワップチェーン再構築 --- private void RecreateSwapchain() { var fb = _window.FramebufferSize; diff --git a/Promete/Graphics/Rendering/Vulkan/VulkanFrameArena.cs b/Promete/Graphics/Rendering/Vulkan/VulkanFrameArena.cs new file mode 100644 index 0000000..2b4c66c --- /dev/null +++ b/Promete/Graphics/Rendering/Vulkan/VulkanFrameArena.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using Silk.NET.Vulkan; +using Buffer = Silk.NET.Vulkan.Buffer; + +namespace Promete.Graphics.Rendering.Vulkan; + +/// +/// フレームごとの動的頂点データ用リングバッファです。 +/// ホスト可視メモリを持続的にマップし、バンプアロケートで割り当てます。 +/// 容量不足時は新しいバッファに切り替え、旧バッファはフレーム完了後に破棄します。 +/// +internal sealed unsafe class VulkanFrameArena : IDisposable +{ + private const ulong InitialCapacity = 256 * 1024; + + private readonly VulkanContext _ctx; + private readonly List<(Buffer Buffer, DeviceMemory Memory)> _retired = []; + + private Buffer _buffer; + private DeviceMemory _memory; + private byte* _mapped; + private ulong _capacity; + private ulong _offset; + private bool _disposed; + + public VulkanFrameArena(VulkanContext ctx) + { + _ctx = ctx; + AllocateBuffer(InitialCapacity); + } + + /// + /// データをアリーナへ書き込み、バッファとオフセットを返します。 + /// + public (Buffer Buffer, ulong Offset) Push(ReadOnlySpan data) + where T : unmanaged + { + var size = (ulong)(data.Length * sizeof(T)); + var aligned = (_offset + 15) & ~15ul; + + if (aligned + size > _capacity) + { + // 旧バッファは記録済みコマンドから参照されているため、フレーム完了まで保持する + _retired.Add((_buffer, _memory)); + var newCapacity = Math.Max(_capacity * 2, aligned + size); + AllocateBuffer(newCapacity); + aligned = 0; + } + + fixed (T* src = data) + { + System.Buffer.MemoryCopy(src, _mapped + aligned, size, size); + } + + _offset = aligned + size; + return (_buffer, aligned); + } + + /// + /// フレーム開始時に呼び出し、オフセットをリセットして退役バッファを破棄します。 + /// このスロットのフェンス待機後に呼び出してください。 + /// + public void Reset() + { + _offset = 0; + if (_retired.Count == 0) + return; + + foreach (var (buffer, memory) in _retired) + DestroyBuffer(buffer, memory); + _retired.Clear(); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + + foreach (var (buffer, memory) in _retired) + DestroyBuffer(buffer, memory); + _retired.Clear(); + _ctx.Vk.UnmapMemory(_ctx.Device, _memory); + DestroyBuffer(_buffer, _memory); + } + + private void AllocateBuffer(ulong capacity) + { + (_buffer, _memory) = _ctx.CreateBuffer( + capacity, + BufferUsageFlags.VertexBufferBit | BufferUsageFlags.IndexBufferBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit + ); + + void* mapped; + _ctx.Vk.MapMemory(_ctx.Device, _memory, 0, capacity, 0, &mapped); + _mapped = (byte*)mapped; + _capacity = capacity; + _offset = 0; + } + + private void DestroyBuffer(Buffer buffer, DeviceMemory memory) + { + _ctx.Vk.DestroyBuffer(_ctx.Device, buffer, null); + _ctx.Vk.FreeMemory(_ctx.Device, memory, null); + } +} diff --git a/Promete/Graphics/Rendering/Vulkan/VulkanPipelineProvider.cs b/Promete/Graphics/Rendering/Vulkan/VulkanPipelineProvider.cs new file mode 100644 index 0000000..391790e --- /dev/null +++ b/Promete/Graphics/Rendering/Vulkan/VulkanPipelineProvider.cs @@ -0,0 +1,424 @@ +using System; +using System.Collections.Generic; +using Silk.NET.Shaderc; +using Silk.NET.Vulkan; + +namespace Promete.Graphics.Rendering.Vulkan; + +/// +/// 描画パイプラインの遅延生成とキャッシュを担います。 +/// レンダーパス互換性により、パイプラインは「オフスクリーン用」「スワップチェーン用」の 2 系統をキャッシュします。 +/// +internal sealed unsafe class VulkanPipelineProvider : IDisposable +{ + private readonly VulkanContext _ctx; + private readonly VulkanResourceManager _resources; + private readonly VulkanShaderCompiler _compiler = new(); + private readonly Dictionary<(PipelineKind Kind, PassClass Pass, PrimitiveTopology Topology), Pipeline> _cache = []; + + private PipelineLayout _textureLayout; + private PipelineLayout _primitiveLayout; + private PipelineLayout _blitLayout; + private bool _initialized; + private bool _disposed; + + public VulkanPipelineProvider(VulkanContext ctx, VulkanResourceManager resources) + { + _ctx = ctx; + _resources = resources; + } + + /// 描画先レンダーパスの系統。 + public enum PassClass + { + Offscreen, + Swapchain, + } + + private enum PipelineKind + { + Texture, + Primitive, + Blit, + } + + /// インスタンシングテクスチャ描画用のパイプラインレイアウトを取得します。 + public PipelineLayout TextureLayout + { + get + { + EnsureInitialized(); + return _textureLayout; + } + } + + /// プリミティブ描画用のパイプラインレイアウトを取得します。 + public PipelineLayout PrimitiveLayout + { + get + { + EnsureInitialized(); + return _primitiveLayout; + } + } + + /// ブリット用のパイプラインレイアウトを取得します。 + public PipelineLayout BlitLayout + { + get + { + EnsureInitialized(); + return _blitLayout; + } + } + + /// インスタンシングテクスチャ描画用のパイプラインを取得します。 + public Pipeline GetTexturePipeline(PassClass pass) => + GetOrCreate(PipelineKind.Texture, pass, PrimitiveTopology.TriangleList); + + /// プリミティブ描画用のパイプラインを取得します。 + public Pipeline GetPrimitivePipeline(PassClass pass, PrimitiveTopology topology) => + GetOrCreate(PipelineKind.Primitive, pass, topology); + + /// フルスクリーンブリット用のパイプラインを取得します。 + public Pipeline GetBlitPipeline(PassClass pass) => + GetOrCreate(PipelineKind.Blit, pass, PrimitiveTopology.TriangleList); + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + + var vk = _ctx.Vk; + var device = _ctx.Device; + + foreach (var pipeline in _cache.Values) + vk.DestroyPipeline(device, pipeline, null); + _cache.Clear(); + + if (_initialized) + { + vk.DestroyPipelineLayout(device, _textureLayout, null); + vk.DestroyPipelineLayout(device, _primitiveLayout, null); + vk.DestroyPipelineLayout(device, _blitLayout, null); + } + + _compiler.Dispose(); + } + + private void EnsureInitialized() + { + if (_initialized) + return; + + var vk = _ctx.Vk; + var device = _ctx.Device; + var textureSetLayout = _resources.TextureSetLayout; + + // texture: set0 = sampler, push constant = mat4 (vertex) + { + var pushConstant = new PushConstantRange(ShaderStageFlags.VertexBit, 0, 64); + var layoutInfo = new PipelineLayoutCreateInfo + { + SType = StructureType.PipelineLayoutCreateInfo, + SetLayoutCount = 1, + PSetLayouts = &textureSetLayout, + PushConstantRangeCount = 1, + PPushConstantRanges = &pushConstant, + }; + vk.CreatePipelineLayout(device, in layoutInfo, null, out _textureLayout); + } + + // primitive: セットなし, push constant = vec4 (fragment) + { + var pushConstant = new PushConstantRange(ShaderStageFlags.FragmentBit, 0, 16); + var layoutInfo = new PipelineLayoutCreateInfo + { + SType = StructureType.PipelineLayoutCreateInfo, + PushConstantRangeCount = 1, + PPushConstantRanges = &pushConstant, + }; + vk.CreatePipelineLayout(device, in layoutInfo, null, out _primitiveLayout); + } + + // blit: set0 = sampler のみ + { + var layoutInfo = new PipelineLayoutCreateInfo + { + SType = StructureType.PipelineLayoutCreateInfo, + SetLayoutCount = 1, + PSetLayouts = &textureSetLayout, + }; + vk.CreatePipelineLayout(device, in layoutInfo, null, out _blitLayout); + } + + _initialized = true; + } + + private Pipeline GetOrCreate(PipelineKind kind, PassClass pass, PrimitiveTopology topology) + { + var key = (kind, pass, topology); + if (_cache.TryGetValue(key, out var cached)) + return cached; + + EnsureInitialized(); + var pipeline = kind switch + { + PipelineKind.Texture => CreateTexturePipeline(pass), + PipelineKind.Primitive => CreatePrimitivePipeline(pass, topology), + PipelineKind.Blit => CreateBlitPipeline(pass), + _ => throw new ArgumentOutOfRangeException(nameof(kind)), + }; + _cache[key] = pipeline; + return pipeline; + } + + private RenderPass GetRenderPass(PassClass pass) => + pass == PassClass.Offscreen ? _ctx.OffscreenClearPass : _ctx.SwapchainPass; + + private Pipeline CreateTexturePipeline(PassClass pass) + { + // binding 0: 頂点 (pos2 + uv2), binding 1: インスタンス (mat4 + tint + uvRect = 24 floats) + var bindings = stackalloc VertexInputBindingDescription[2] + { + new VertexInputBindingDescription(0, 16, VertexInputRate.Vertex), + new VertexInputBindingDescription(1, 96, VertexInputRate.Instance), + }; + + var attributes = stackalloc VertexInputAttributeDescription[8] + { + new VertexInputAttributeDescription(0, 0, Format.R32G32Sfloat, 0), + new VertexInputAttributeDescription(1, 0, Format.R32G32Sfloat, 8), + new VertexInputAttributeDescription(2, 1, Format.R32G32B32A32Sfloat, 0), + new VertexInputAttributeDescription(3, 1, Format.R32G32B32A32Sfloat, 16), + new VertexInputAttributeDescription(4, 1, Format.R32G32B32A32Sfloat, 32), + new VertexInputAttributeDescription(5, 1, Format.R32G32B32A32Sfloat, 48), + new VertexInputAttributeDescription(6, 1, Format.R32G32B32A32Sfloat, 64), + new VertexInputAttributeDescription(7, 1, Format.R32G32B32A32Sfloat, 80), + }; + + return CreatePipeline( + "texture_instanced", + _textureLayout, + GetRenderPass(pass), + PrimitiveTopology.TriangleList, + enableBlend: true, + bindings, + 2, + attributes, + 8 + ); + } + + private Pipeline CreatePrimitivePipeline(PassClass pass, PrimitiveTopology topology) + { + var bindings = stackalloc VertexInputBindingDescription[1] + { + new VertexInputBindingDescription(0, 8, VertexInputRate.Vertex), + }; + var attributes = stackalloc VertexInputAttributeDescription[1] + { + new VertexInputAttributeDescription(0, 0, Format.R32G32Sfloat, 0), + }; + + return CreatePipeline( + "primitive", + _primitiveLayout, + GetRenderPass(pass), + topology, + enableBlend: true, + bindings, + 1, + attributes, + 1 + ); + } + + private Pipeline CreateBlitPipeline(PassClass pass) + { + return CreatePipeline( + "blit", + _blitLayout, + GetRenderPass(pass), + PrimitiveTopology.TriangleList, + enableBlend: false, + null, + 0, + null, + 0 + ); + } + + private Pipeline CreatePipeline( + string shaderName, + PipelineLayout layout, + RenderPass renderPass, + PrimitiveTopology topology, + bool enableBlend, + VertexInputBindingDescription* bindings, + uint bindingCount, + VertexInputAttributeDescription* attributes, + uint attributeCount + ) + { + var vk = _ctx.Vk; + var device = _ctx.Device; + + var vertSpv = _compiler.Compile( + EmbeddedResource.GetResourceAsString($"Promete.Resources.shaders.vulkan.{shaderName}.vert"), + ShaderKind.VertexShader, + $"{shaderName}.vert" + ); + var fragSpv = _compiler.Compile( + EmbeddedResource.GetResourceAsString($"Promete.Resources.shaders.vulkan.{shaderName}.frag"), + ShaderKind.FragmentShader, + $"{shaderName}.frag" + ); + + var vertModule = CreateShaderModule(vertSpv); + var fragModule = CreateShaderModule(fragSpv); + + var entryPoint = (byte*)Silk.NET.Core.Native.SilkMarshal.StringToPtr("main"); + var stages = stackalloc PipelineShaderStageCreateInfo[2] + { + new PipelineShaderStageCreateInfo + { + SType = StructureType.PipelineShaderStageCreateInfo, + Stage = ShaderStageFlags.VertexBit, + Module = vertModule, + PName = entryPoint, + }, + new PipelineShaderStageCreateInfo + { + SType = StructureType.PipelineShaderStageCreateInfo, + Stage = ShaderStageFlags.FragmentBit, + Module = fragModule, + PName = entryPoint, + }, + }; + + var vertexInput = new PipelineVertexInputStateCreateInfo + { + SType = StructureType.PipelineVertexInputStateCreateInfo, + VertexBindingDescriptionCount = bindingCount, + PVertexBindingDescriptions = bindings, + VertexAttributeDescriptionCount = attributeCount, + PVertexAttributeDescriptions = attributes, + }; + + var inputAssembly = new PipelineInputAssemblyStateCreateInfo + { + SType = StructureType.PipelineInputAssemblyStateCreateInfo, + Topology = topology, + }; + + var viewportState = new PipelineViewportStateCreateInfo + { + SType = StructureType.PipelineViewportStateCreateInfo, + ViewportCount = 1, + ScissorCount = 1, + }; + + var rasterization = new PipelineRasterizationStateCreateInfo + { + SType = StructureType.PipelineRasterizationStateCreateInfo, + PolygonMode = PolygonMode.Fill, + CullMode = CullModeFlags.None, + FrontFace = FrontFace.Clockwise, + LineWidth = 1f, + }; + + var multisample = new PipelineMultisampleStateCreateInfo + { + SType = StructureType.PipelineMultisampleStateCreateInfo, + RasterizationSamples = SampleCountFlags.Count1Bit, + }; + + var blendAttachment = new PipelineColorBlendAttachmentState + { + BlendEnable = enableBlend, + SrcColorBlendFactor = BlendFactor.SrcAlpha, + DstColorBlendFactor = BlendFactor.OneMinusSrcAlpha, + ColorBlendOp = BlendOp.Add, + SrcAlphaBlendFactor = BlendFactor.One, + DstAlphaBlendFactor = BlendFactor.OneMinusSrcAlpha, + AlphaBlendOp = BlendOp.Add, + ColorWriteMask = + ColorComponentFlags.RBit + | ColorComponentFlags.GBit + | ColorComponentFlags.BBit + | ColorComponentFlags.ABit, + }; + + var colorBlend = new PipelineColorBlendStateCreateInfo + { + SType = StructureType.PipelineColorBlendStateCreateInfo, + AttachmentCount = 1, + PAttachments = &blendAttachment, + }; + + var dynamicStates = stackalloc DynamicState[2] + { + DynamicState.Viewport, + DynamicState.Scissor, + }; + var dynamicState = new PipelineDynamicStateCreateInfo + { + SType = StructureType.PipelineDynamicStateCreateInfo, + DynamicStateCount = 2, + PDynamicStates = dynamicStates, + }; + + var createInfo = new GraphicsPipelineCreateInfo + { + SType = StructureType.GraphicsPipelineCreateInfo, + StageCount = 2, + PStages = stages, + PVertexInputState = &vertexInput, + PInputAssemblyState = &inputAssembly, + PViewportState = &viewportState, + PRasterizationState = &rasterization, + PMultisampleState = &multisample, + PColorBlendState = &colorBlend, + PDynamicState = &dynamicState, + Layout = layout, + RenderPass = renderPass, + Subpass = 0, + }; + + var result = vk.CreateGraphicsPipelines( + device, + default, + 1, + in createInfo, + null, + out var pipeline + ); + + Silk.NET.Core.Native.SilkMarshal.Free((nint)entryPoint); + vk.DestroyShaderModule(device, vertModule, null); + vk.DestroyShaderModule(device, fragModule, null); + + if (result != Result.Success) + throw new InvalidOperationException($"パイプラインの作成に失敗しました: {result}"); + return pipeline; + } + + private ShaderModule CreateShaderModule(byte[] spirv) + { + fixed (byte* code = spirv) + { + var createInfo = new ShaderModuleCreateInfo + { + SType = StructureType.ShaderModuleCreateInfo, + CodeSize = (nuint)spirv.Length, + PCode = (uint*)code, + }; + var result = _ctx.Vk.CreateShaderModule(_ctx.Device, in createInfo, null, out var module); + if (result != Result.Success) + throw new InvalidOperationException($"シェーダーモジュールの作成に失敗しました: {result}"); + return module; + } + } +} diff --git a/Promete/Graphics/Rendering/Vulkan/VulkanRenderTarget.cs b/Promete/Graphics/Rendering/Vulkan/VulkanRenderTarget.cs new file mode 100644 index 0000000..d9c52f5 --- /dev/null +++ b/Promete/Graphics/Rendering/Vulkan/VulkanRenderTarget.cs @@ -0,0 +1,27 @@ +using Silk.NET.Vulkan; + +namespace Promete.Graphics.Rendering.Vulkan; + +/// +/// オフスクリーン描画先のイメージ・フレームバッファ一式を保持します。 +/// +internal sealed class VulkanRenderTarget +{ + /// カラーアタッチメントのイメージ。 + public required Image Image { get; set; } + + /// イメージのメモリ。 + public required DeviceMemory Memory { get; set; } + + /// イメージビュー。 + public required ImageView View { get; set; } + + /// オフスクリーンパス用フレームバッファ。 + public required Framebuffer Framebuffer { get; set; } + + /// ターゲットの大きさ。 + public required Extent2D Extent { get; set; } + + /// リソーステーブル上のテクスチャ ID( と同一)。 + public required int TextureId { get; init; } +} diff --git a/Promete/Graphics/Rendering/Vulkan/VulkanRenderTextureProvider.cs b/Promete/Graphics/Rendering/Vulkan/VulkanRenderTextureProvider.cs new file mode 100644 index 0000000..f216d6b --- /dev/null +++ b/Promete/Graphics/Rendering/Vulkan/VulkanRenderTextureProvider.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using Silk.NET.Vulkan; + +namespace Promete.Graphics.Rendering.Vulkan; + +/// +/// Vulkan バックエンドにおける の実装です。 +/// +internal sealed unsafe class VulkanRenderTextureProvider( + VulkanContext ctx, + VulkanResourceManager resources +) : IRenderTextureProvider +{ + private readonly Dictionary _targets = []; + + public RenderTexture Create(VectorInt size) + { + var target = CreateTarget(size, textureId: null); + var texture = new Texture2D(target.TextureId, size, _ => { }); + var rt = new RenderTexture(size, texture, this); + _targets[rt] = target; + return rt; + } + + public IDisposable BeginCapture(RenderTexture renderTexture, Color? clearColor = null) + { + var target = _targets[renderTexture]; + ctx.PushRenderTarget(target, clearColor); + return new CaptureScope(ctx); + } + + public void Resize(RenderTexture renderTexture, VectorInt newSize) + { + var target = _targets[renderTexture]; + if (target.Extent.Width == (uint)newSize.X && target.Extent.Height == (uint)newSize.Y) + return; + + // 使用中リソースを差し替えるため、デバイスの完了を待つ + ctx.WaitIdle(); + DestroyTargetResources(target); + + var newTarget = CreateTarget(newSize, target.TextureId); + target.Image = newTarget.Image; + target.Memory = newTarget.Memory; + target.View = newTarget.View; + target.Framebuffer = newTarget.Framebuffer; + target.Extent = newTarget.Extent; + + renderTexture.Texture = new Texture2D(target.TextureId, newSize, _ => { }); + } + + public void Release(RenderTexture renderTexture) + { + if (!_targets.Remove(renderTexture, out var target)) + return; + + resources.Destroy(target.TextureId); + var vk = ctx.Vk; + var device = ctx.Device; + var framebuffer = target.Framebuffer; + var view = target.View; + var image = target.Image; + var memory = target.Memory; + ctx.DeferDestroy(() => + { + vk.DestroyFramebuffer(device, framebuffer, null); + vk.DestroyImageView(device, view, null); + vk.DestroyImage(device, image, null); + vk.FreeMemory(device, memory, null); + }); + } + + /// + /// 内部レンダーターゲットを取得します。(ブリッター・スクリーンショット用) + /// + internal VulkanRenderTarget GetTarget(RenderTexture renderTexture) => _targets[renderTexture]; + + private VulkanRenderTarget CreateTarget(VectorInt size, int? textureId) + { + var width = (uint)Math.Max(1, size.X); + var height = (uint)Math.Max(1, size.Y); + + var (image, memory) = ctx.CreateImage2D( + width, + height, + VulkanContext.OffscreenFormat, + ImageUsageFlags.ColorAttachmentBit + | ImageUsageFlags.SampledBit + | ImageUsageFlags.TransferSrcBit + ); + + // レンダーパスの initialLayout (General) に合わせて遷移しておく + ctx.ExecuteOneTime(cmd => + ctx.TransitionImageLayout( + cmd, + image, + ImageLayout.Undefined, + ImageLayout.General, + PipelineStageFlags.TopOfPipeBit, + 0, + PipelineStageFlags.ColorAttachmentOutputBit | PipelineStageFlags.FragmentShaderBit, + AccessFlags.ColorAttachmentWriteBit | AccessFlags.ShaderReadBit + ) + ); + + var view = ctx.CreateImageView2D(image, VulkanContext.OffscreenFormat); + var framebuffer = ctx.CreateOffscreenFramebuffer(view, width, height); + + int id; + if (textureId is { } existingId) + { + resources.Replace(existingId, image, memory, view); + id = existingId; + } + else + { + id = resources.Register(image, memory, view, ownsImage: false); + } + + return new VulkanRenderTarget + { + Image = image, + Memory = memory, + View = view, + Framebuffer = framebuffer, + Extent = new Extent2D(width, height), + TextureId = id, + }; + } + + private void DestroyTargetResources(VulkanRenderTarget target) + { + var vk = ctx.Vk; + var device = ctx.Device; + vk.DestroyFramebuffer(device, target.Framebuffer, null); + vk.DestroyImageView(device, target.View, null); + vk.DestroyImage(device, target.Image, null); + vk.FreeMemory(device, target.Memory, null); + } + + private sealed class CaptureScope(VulkanContext ctx) : IDisposable + { + public void Dispose() => ctx.PopRenderTarget(); + } +} diff --git a/Promete/Graphics/Rendering/Vulkan/VulkanResourceManager.cs b/Promete/Graphics/Rendering/Vulkan/VulkanResourceManager.cs new file mode 100644 index 0000000..76529c0 --- /dev/null +++ b/Promete/Graphics/Rendering/Vulkan/VulkanResourceManager.cs @@ -0,0 +1,332 @@ +using System; +using System.Collections.Generic; +using Silk.NET.Vulkan; +using Buffer = Silk.NET.Vulkan.Buffer; + +namespace Promete.Graphics.Rendering.Vulkan; + +/// +/// テクスチャリソース (VkImage / ImageView / DescriptorSet) を int の ID で管理するテーブルです。 +/// はこのテーブルの ID を指します。 +/// +internal sealed unsafe class VulkanResourceManager : IDisposable +{ + private const uint MaxDescriptorSets = 4096; + + private readonly VulkanContext _ctx; + private readonly Dictionary _textures = []; + + private DescriptorPool _descriptorPool; + private DescriptorSetLayout _textureSetLayout; + private Sampler _nearestSampler; + private int _nextId = 1; + private bool _initialized; + private bool _disposed; + + public VulkanResourceManager(VulkanContext ctx) + { + _ctx = ctx; + } + + /// テクスチャ 1 枚 (combined image sampler) 用のディスクリプタセットレイアウトを取得します。 + public DescriptorSetLayout TextureSetLayout + { + get + { + EnsureInitialized(); + return _textureSetLayout; + } + } + + /// + /// RGBA8 のピクセルデータからテクスチャを作成し、ID を返します。 + /// + public int CreateTexture(ReadOnlySpan rgba, uint width, uint height) + { + EnsureInitialized(); + + var (image, memory) = _ctx.CreateImage2D( + width, + height, + VulkanContext.OffscreenFormat, + ImageUsageFlags.SampledBit | ImageUsageFlags.TransferDstBit + ); + + UploadPixels(image, rgba, width, height); + + var view = _ctx.CreateImageView2D(image, VulkanContext.OffscreenFormat); + return Register(image, memory, view, ownsImage: true); + } + + /// + /// 既存のイメージ (RenderTexture 等) をテーブルに登録し、ID を返します。 + /// + public int Register(Image image, DeviceMemory memory, ImageView view, bool ownsImage) + { + EnsureInitialized(); + + var descriptorSet = AllocateTextureDescriptorSet(view); + var id = _nextId++; + _textures[id] = new VulkanTextureEntry + { + Image = image, + Memory = memory, + View = view, + DescriptorSet = descriptorSet, + OwnsImage = ownsImage, + }; + return id; + } + + /// + /// 登録済みイメージの差し替え(RenderTexture のリサイズ用)。ディスクリプタセットも更新します。 + /// 呼び出し前にデバイスがアイドルであることを保証してください。 + /// + public void Replace(int id, Image image, DeviceMemory memory, ImageView view) + { + var entry = _textures[id]; + entry.Image = image; + entry.Memory = memory; + entry.View = view; + UpdateTextureDescriptorSet(entry.DescriptorSet, view); + } + + /// + /// テクスチャのディスクリプタセットを取得します。 + /// + public DescriptorSet GetDescriptorSet(int id) => _textures[id].DescriptorSet; + + /// + /// テクスチャのイメージを取得します。 + /// + public Image GetImage(int id) => _textures[id].Image; + + /// + /// テクスチャが登録されているかを取得します。 + /// + public bool Contains(int id) => _textures.ContainsKey(id); + + /// + /// テクスチャを破棄します。GPU が使用中の可能性があるため、実際の破棄は遅延されます。 + /// + public void Destroy(int id) + { + if (!_textures.Remove(id, out var entry)) + return; + + var vk = _ctx.Vk; + var device = _ctx.Device; + _ctx.DeferDestroy(() => + { + var set = entry.DescriptorSet; + vk.FreeDescriptorSets(device, _descriptorPool, 1, in set); + if (entry.OwnsImage) + { + vk.DestroyImageView(device, entry.View, null); + vk.DestroyImage(device, entry.Image, null); + vk.FreeMemory(device, entry.Memory, null); + } + }); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + + var vk = _ctx.Vk; + var device = _ctx.Device; + + foreach (var entry in _textures.Values) + { + if (!entry.OwnsImage) + continue; + vk.DestroyImageView(device, entry.View, null); + vk.DestroyImage(device, entry.Image, null); + vk.FreeMemory(device, entry.Memory, null); + } + + _textures.Clear(); + + if (_initialized) + { + vk.DestroySampler(device, _nearestSampler, null); + vk.DestroyDescriptorPool(device, _descriptorPool, null); + vk.DestroyDescriptorSetLayout(device, _textureSetLayout, null); + } + } + + private void EnsureInitialized() + { + if (_initialized) + return; + Initialize(); + _initialized = true; + } + + private void Initialize() + { + var vk = _ctx.Vk; + var device = _ctx.Device; + + // ディスクリプタプール + var poolSize = new DescriptorPoolSize + { + Type = DescriptorType.CombinedImageSampler, + DescriptorCount = MaxDescriptorSets, + }; + var poolInfo = new DescriptorPoolCreateInfo + { + SType = StructureType.DescriptorPoolCreateInfo, + Flags = DescriptorPoolCreateFlags.FreeDescriptorSetBit, + MaxSets = MaxDescriptorSets, + PoolSizeCount = 1, + PPoolSizes = &poolSize, + }; + vk.CreateDescriptorPool(device, in poolInfo, null, out _descriptorPool); + + // セットレイアウト: binding 0 = combined image sampler (fragment) + var binding = new DescriptorSetLayoutBinding + { + Binding = 0, + DescriptorType = DescriptorType.CombinedImageSampler, + DescriptorCount = 1, + StageFlags = ShaderStageFlags.FragmentBit, + }; + var layoutInfo = new DescriptorSetLayoutCreateInfo + { + SType = StructureType.DescriptorSetLayoutCreateInfo, + BindingCount = 1, + PBindings = &binding, + }; + vk.CreateDescriptorSetLayout(device, in layoutInfo, null, out _textureSetLayout); + + // Nearest サンプラー (ピクセルパーフェクト描画用、ClampToEdge) + var samplerInfo = new SamplerCreateInfo + { + SType = StructureType.SamplerCreateInfo, + MagFilter = Filter.Nearest, + MinFilter = Filter.Nearest, + MipmapMode = SamplerMipmapMode.Nearest, + AddressModeU = SamplerAddressMode.ClampToEdge, + AddressModeV = SamplerAddressMode.ClampToEdge, + AddressModeW = SamplerAddressMode.ClampToEdge, + MaxLod = 0, + }; + vk.CreateSampler(device, in samplerInfo, null, out _nearestSampler); + } + + private DescriptorSet AllocateTextureDescriptorSet(ImageView view) + { + var layout = _textureSetLayout; + var allocInfo = new DescriptorSetAllocateInfo + { + SType = StructureType.DescriptorSetAllocateInfo, + DescriptorPool = _descriptorPool, + DescriptorSetCount = 1, + PSetLayouts = &layout, + }; + var result = _ctx.Vk.AllocateDescriptorSets(_ctx.Device, in allocInfo, out var set); + if (result != Result.Success) + throw new InvalidOperationException($"ディスクリプタセットの確保に失敗しました: {result}"); + + UpdateTextureDescriptorSet(set, view); + return set; + } + + private void UpdateTextureDescriptorSet(DescriptorSet set, ImageView view) + { + var imageInfo = new DescriptorImageInfo + { + Sampler = _nearestSampler, + ImageView = view, + ImageLayout = ImageLayout.General, + }; + var write = new WriteDescriptorSet + { + SType = StructureType.WriteDescriptorSet, + DstSet = set, + DstBinding = 0, + DescriptorCount = 1, + DescriptorType = DescriptorType.CombinedImageSampler, + PImageInfo = &imageInfo, + }; + _ctx.Vk.UpdateDescriptorSets(_ctx.Device, 1, in write, 0, null); + } + + private void UploadPixels(Image image, ReadOnlySpan rgba, uint width, uint height) + { + var vk = _ctx.Vk; + var device = _ctx.Device; + var size = (ulong)(width * height * 4); + + var (staging, stagingMemory) = _ctx.CreateBuffer( + size, + BufferUsageFlags.TransferSrcBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit + ); + + void* mapped; + vk.MapMemory(device, stagingMemory, 0, size, 0, &mapped); + fixed (byte* src = rgba) + { + System.Buffer.MemoryCopy(src, mapped, size, size); + } + + vk.UnmapMemory(device, stagingMemory); + + _ctx.ExecuteOneTime(cmd => + { + _ctx.TransitionImageLayout( + cmd, + image, + ImageLayout.Undefined, + ImageLayout.TransferDstOptimal, + PipelineStageFlags.TopOfPipeBit, + 0, + PipelineStageFlags.TransferBit, + AccessFlags.TransferWriteBit + ); + + var region = new BufferImageCopy + { + BufferOffset = 0, + BufferRowLength = 0, + BufferImageHeight = 0, + ImageSubresource = new ImageSubresourceLayers(ImageAspectFlags.ColorBit, 0, 0, 1), + ImageOffset = new Offset3D(0, 0, 0), + ImageExtent = new Extent3D(width, height, 1), + }; + vk.CmdCopyBufferToImage(cmd, staging, image, ImageLayout.TransferDstOptimal, 1, in region); + + // サンプリング時のレイアウト管理を単純化するため General に統一する + _ctx.TransitionImageLayout( + cmd, + image, + ImageLayout.TransferDstOptimal, + ImageLayout.General, + PipelineStageFlags.TransferBit, + AccessFlags.TransferWriteBit, + PipelineStageFlags.FragmentShaderBit, + AccessFlags.ShaderReadBit + ); + }); + + vk.DestroyBuffer(device, staging, null); + vk.FreeMemory(device, stagingMemory, null); + } + + private sealed class VulkanTextureEntry + { + public required Image Image { get; set; } + + public required DeviceMemory Memory { get; set; } + + public required ImageView View { get; set; } + + public required DescriptorSet DescriptorSet { get; init; } + + public required bool OwnsImage { get; init; } + } +} diff --git a/Promete/Graphics/Rendering/Vulkan/VulkanScreenBlitter.cs b/Promete/Graphics/Rendering/Vulkan/VulkanScreenBlitter.cs new file mode 100644 index 0000000..927c820 --- /dev/null +++ b/Promete/Graphics/Rendering/Vulkan/VulkanScreenBlitter.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using Promete.Backends; +using Promete.Internal; +using Silk.NET.Vulkan; + +namespace Promete.Graphics.Rendering.Vulkan; + +/// +/// 全描画をスクリーンサイズの にキャプチャし、 +/// スワップチェーンイメージへブリットするクラスです。 +/// +internal sealed class VulkanScreenBlitter : IScreenBlitter +{ + private readonly VulkanContext _ctx; + private readonly VulkanResourceManager _resources; + private readonly VulkanPipelineProvider _pipelines; + private readonly VulkanRenderTextureProvider _provider; + private readonly IGameView _view; + private bool _postProcessWarned; + + public VulkanScreenBlitter( + VulkanContext ctx, + VulkanResourceManager resources, + VulkanPipelineProvider pipelines, + VulkanRenderTextureProvider provider, + IGameView view + ) + { + _ctx = ctx; + _resources = resources; + _pipelines = pipelines; + _provider = provider; + _view = view; + _view.Resize += OnViewResize; + } + + /// + /// 全描画のキャプチャ先 RenderTexture を取得します。 + /// + public RenderTexture ScreenRenderTexture { get; private set; } = null!; + + public void InitializeScreenRenderTexture() + { + ScreenRenderTexture = _provider.Create(_view.Size); + } + + public unsafe void BlitToScreen(IReadOnlyList materials) + { + // TODO: Phase 3+ でポストプロセスマテリアルに対応する + if (materials.Count > 0 && !_postProcessWarned) + { + _postProcessWarned = true; + LogHelper.Bug("Vulkan バックエンドはまだポストプロセスマテリアルをサポートしていません。"); + } + + var vk = _ctx.Vk; + _ctx.BeginSwapchainPass(Color.Black); + + var cmd = _ctx.CurrentCommandBuffer; + var pipeline = _pipelines.GetBlitPipeline(VulkanPipelineProvider.PassClass.Swapchain); + vk.CmdBindPipeline(cmd, PipelineBindPoint.Graphics, pipeline); + + var descriptorSet = _resources.GetDescriptorSet(ScreenRenderTexture.Texture.Handle); + vk.CmdBindDescriptorSets( + cmd, + PipelineBindPoint.Graphics, + _pipelines.BlitLayout, + 0, + 1, + in descriptorSet, + 0, + null + ); + + // フルスクリーントライアングル(頂点バッファ不要) + vk.CmdDraw(cmd, 3, 1, 0, 0); + + _ctx.EndSwapchainPass(); + } + + private void OnViewResize() + { + var size = _view.Size; + ScreenRenderTexture.Resize(size); + } +} diff --git a/Promete/Graphics/Rendering/Vulkan/VulkanShaderCompiler.cs b/Promete/Graphics/Rendering/Vulkan/VulkanShaderCompiler.cs new file mode 100644 index 0000000..718c6b4 --- /dev/null +++ b/Promete/Graphics/Rendering/Vulkan/VulkanShaderCompiler.cs @@ -0,0 +1,82 @@ +using System; +using Silk.NET.Core.Native; +using Silk.NET.Shaderc; + +namespace Promete.Graphics.Rendering.Vulkan; + +/// +/// shaderc による GLSL → SPIR-V のランタイムコンパイルを提供します。 +/// +internal sealed unsafe class VulkanShaderCompiler : IDisposable +{ + private readonly Shaderc _shaderc = Shaderc.GetApi(); + private readonly Compiler* _compiler; + private bool _disposed; + + public VulkanShaderCompiler() + { + _compiler = _shaderc.CompilerInitialize(); + if (_compiler == null) + throw new InvalidOperationException("shaderc コンパイラの初期化に失敗しました。"); + } + + /// + /// GLSL ソースコードを SPIR-V にコンパイルします。 + /// + /// Vulkan 方言の GLSL ソースコード。 + /// シェーダーステージ。 + /// エラーメッセージに使用する名前。 + /// SPIR-V バイトコード。 + public byte[] Compile(string source, ShaderKind kind, string name) + { + var options = _shaderc.CompileOptionsInitialize(); + _shaderc.CompileOptionsSetTargetEnv(options, TargetEnv.Vulkan, (uint)EnvVersion.Vulkan12); + + var result = _shaderc.CompileIntoSpv( + _compiler, + source, + (nuint)System.Text.Encoding.UTF8.GetByteCount(source), + kind, + name, + "main", + options + ); + + try + { + var status = _shaderc.ResultGetCompilationStatus(result); + if (status != CompilationStatus.Success) + { + var message = SilkMarshal.PtrToString( + (nint)_shaderc.ResultGetErrorMessage(result) + ); + throw new InvalidOperationException( + $"シェーダーのコンパイルエラー ({name}): {message}" + ); + } + + var length = _shaderc.ResultGetLength(result); + var bytes = new byte[length]; + fixed (byte* dst = bytes) + { + System.Buffer.MemoryCopy(_shaderc.ResultGetBytes(result), dst, (long)length, (long)length); + } + + return bytes; + } + finally + { + _shaderc.ResultRelease(result); + _shaderc.CompileOptionsRelease(options); + } + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + _shaderc.CompilerRelease(_compiler); + _shaderc.Dispose(); + } +} diff --git a/Promete/Graphics/Rendering/Vulkan/VulkanShaderFactory.cs b/Promete/Graphics/Rendering/Vulkan/VulkanShaderFactory.cs new file mode 100644 index 0000000..981b897 --- /dev/null +++ b/Promete/Graphics/Rendering/Vulkan/VulkanShaderFactory.cs @@ -0,0 +1,19 @@ +using System; + +namespace Promete.Graphics.Rendering.Vulkan; + +/// +/// Vulkan バックエンドにおける の実装です。 +/// +/// +/// TODO: Phase 3+ でカスタムシェーダー (Vulkan 方言 GLSL 450) のコンパイルとマテリアル適用に対応する。 +/// +internal sealed class VulkanShaderFactory : IShaderFactory +{ + public void Compile(ShaderProgram program) + { + throw new NotSupportedException( + "Vulkan バックエンドはまだカスタムシェーダーをサポートしていません。" + ); + } +} diff --git a/Promete/Graphics/Rendering/Vulkan/VulkanTextureFactory.cs b/Promete/Graphics/Rendering/Vulkan/VulkanTextureFactory.cs new file mode 100644 index 0000000..f4baebf --- /dev/null +++ b/Promete/Graphics/Rendering/Vulkan/VulkanTextureFactory.cs @@ -0,0 +1,139 @@ +using System; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Advanced; +using SixLabors.ImageSharp.PixelFormats; +using Color = System.Drawing.Color; + +namespace Promete.Graphics.Rendering.Vulkan; + +/// +/// Vulkan バックエンドにおける の実装です。 +/// +internal sealed class VulkanTextureFactory(PrometeApp app, VulkanResourceManager resources) + : TextureFactoryBase +{ + public override Texture2D Load(string path) + { + return LoadFromImageSharpImage(Image.Load(path)); + } + + public override Texture2D Load(Stream stream) + { + return LoadFromImageSharpImage(Image.Load(stream)); + } + + public override Texture2D[] LoadSpriteSheet( + string path, + int horizontalCount, + int verticalCount, + VectorInt size + ) + { + return LoadSpriteSheet(Image.Load(path), horizontalCount, verticalCount, size); + } + + public override Texture2D[] LoadSpriteSheet( + Stream stream, + int horizontalCount, + int verticalCount, + VectorInt size + ) + { + return LoadSpriteSheet(Image.Load(stream), horizontalCount, verticalCount, size); + } + + public override Texture2D Create(byte[] bitmap, VectorInt size) + { + app.ThrowIfNotMainThread(); + var id = resources.CreateTexture(bitmap, (uint)size.X, (uint)size.Y); + return new Texture2D(id, size, DisposeTexture); + } + + public override Texture2D Create(byte[,,] bitmap) + { + var width = bitmap.GetLength(0); + var height = bitmap.GetLength(1); + var arr = new byte[width * height * 4]; + for (int y = 0, i = 0; y < height; y++) + for (var x = 0; x < width; x++) + for (var j = 0; j < 4; j++) + arr[i++] = bitmap[x, y, j]; + + return Create(arr, (width, height)); + } + + public override Texture2D CreateSolid(Color color, VectorInt size) + { + var arr = new byte[size.X * size.Y * 4]; + for (var i = 0; i < arr.Length; i += 4) + { + arr[i + 0] = color.R; + arr[i + 1] = color.G; + arr[i + 2] = color.B; + arr[i + 3] = color.A; + } + + return Create(arr, size); + } + + internal override Texture2D LoadFromImageSharpImage(Image image) + { + using var img = image.CloneAs(); + + var rgbaBytes = MemoryMarshal + .AsBytes(img.GetPixelMemoryGroup().ToArray()[0].Span) + .ToArray(); + image.Dispose(); + return Create(rgbaBytes, (img.Width, img.Height)); + } + + private Texture2D[] LoadSpriteSheet( + Image bmp, + int horizontalCount, + int verticalCount, + VectorInt size + ) + { + var width = (float)bmp.Width; + var height = (float)bmp.Height; + var handle = LoadFromImageSharpImage(bmp).Handle; + + var textures = new Texture2D[verticalCount * horizontalCount]; + for (var y = 0; y < verticalCount; y++) + { + for (var x = 0; x < horizontalCount; x++) + { + var px = x * size.X; + var py = y * size.Y; + + if (px + size.X > width) + throw new ArgumentException(null, nameof(horizontalCount)); + if (py + size.Y > height) + throw new ArgumentException(null, nameof(verticalCount)); + + var uvStart = new Vector(px / width, py / height); + var uvEnd = new Vector((px + size.X) / width, (py + size.Y) / height); + + textures[(y * horizontalCount) + x] = new Texture2D( + handle, + size, + DisposeTexture, + uvStart, + uvEnd + ); + } + } + + bmp.Dispose(); + return textures; + } + + private void DisposeTexture(Texture2D texture) + { + app.ThrowIfNotMainThread(); + resources.Destroy(texture.Handle); + } +} diff --git a/Promete/Promete.csproj b/Promete/Promete.csproj index 14d21ff..9b12e0d 100644 --- a/Promete/Promete.csproj +++ b/Promete/Promete.csproj @@ -29,6 +29,8 @@ + + @@ -36,6 +38,8 @@ + + diff --git a/Promete/Resources/shaders/vulkan/blit.frag b/Promete/Resources/shaders/vulkan/blit.frag new file mode 100644 index 0000000..e462506 --- /dev/null +++ b/Promete/Resources/shaders/vulkan/blit.frag @@ -0,0 +1,11 @@ +#version 450 +layout(location = 0) in vec2 fUv; + +layout(set = 0, binding = 0) uniform sampler2D uScreenTexture; + +layout(location = 0) out vec4 FragColor; + +void main() +{ + FragColor = texture(uScreenTexture, fUv); +} diff --git a/Promete/Resources/shaders/vulkan/blit.vert b/Promete/Resources/shaders/vulkan/blit.vert new file mode 100644 index 0000000..135048a --- /dev/null +++ b/Promete/Resources/shaders/vulkan/blit.vert @@ -0,0 +1,10 @@ +#version 450 +layout(location = 0) out vec2 fUv; + +// フルスクリーントライアングル (頂点バッファ不要) +void main() +{ + vec2 pos = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2); + fUv = pos; + gl_Position = vec4(pos * 2.0 - 1.0, 0.0, 1.0); +} diff --git a/Promete/Resources/shaders/vulkan/primitive.frag b/Promete/Resources/shaders/vulkan/primitive.frag new file mode 100644 index 0000000..f0d3c3e --- /dev/null +++ b/Promete/Resources/shaders/vulkan/primitive.frag @@ -0,0 +1,12 @@ +#version 450 +layout(push_constant) uniform PushConstants +{ + vec4 uTintColor; +}; + +layout(location = 0) out vec4 FragColor; + +void main() +{ + FragColor = uTintColor; +} diff --git a/Promete/Resources/shaders/vulkan/primitive.vert b/Promete/Resources/shaders/vulkan/primitive.vert new file mode 100644 index 0000000..f503702 --- /dev/null +++ b/Promete/Resources/shaders/vulkan/primitive.vert @@ -0,0 +1,8 @@ +#version 450 +layout(location = 0) in vec2 vPos; + +void main() +{ + gl_Position = vec4(vPos.x, vPos.y, 0.0, 1.0); + gl_PointSize = 1.0; +} diff --git a/Promete/Resources/shaders/vulkan/texture_instanced.frag b/Promete/Resources/shaders/vulkan/texture_instanced.frag new file mode 100644 index 0000000..6a781ef --- /dev/null +++ b/Promete/Resources/shaders/vulkan/texture_instanced.frag @@ -0,0 +1,12 @@ +#version 450 +layout(location = 0) in vec2 fUv; +layout(location = 1) in vec4 fTintColor; + +layout(set = 0, binding = 0) uniform sampler2D uTexture0; + +layout(location = 0) out vec4 FragColor; + +void main() +{ + FragColor = texture(uTexture0, fUv) * fTintColor; +} diff --git a/Promete/Resources/shaders/vulkan/texture_instanced.vert b/Promete/Resources/shaders/vulkan/texture_instanced.vert new file mode 100644 index 0000000..884246d --- /dev/null +++ b/Promete/Resources/shaders/vulkan/texture_instanced.vert @@ -0,0 +1,25 @@ +#version 450 +layout(location = 0) in vec2 vPos; +layout(location = 1) in vec2 vUv; +layout(location = 2) in vec4 iModel0; +layout(location = 3) in vec4 iModel1; +layout(location = 4) in vec4 iModel2; +layout(location = 5) in vec4 iModel3; +layout(location = 6) in vec4 iTintColor; +layout(location = 7) in vec4 iUvRect; // xy = uvStart, zw = uvEnd + +layout(location = 0) out vec2 fUv; +layout(location = 1) out vec4 fTintColor; + +layout(push_constant) uniform PushConstants +{ + mat4 uProjection; +}; + +void main() +{ + mat4 model = mat4(iModel0, iModel1, iModel2, iModel3); + gl_Position = uProjection * model * vec4(vPos, 0.0, 1.0); + fUv = mix(iUvRect.xy, iUvRect.zw, vUv); + fTintColor = iTintColor; +} diff --git a/Promete/VulkanDesktop/VulkanDesktopAppExtension.cs b/Promete/VulkanDesktop/VulkanDesktopAppExtension.cs index 3cbbcba..383faa7 100644 --- a/Promete/VulkanDesktop/VulkanDesktopAppExtension.cs +++ b/Promete/VulkanDesktop/VulkanDesktopAppExtension.cs @@ -13,8 +13,9 @@ public static class VulkanDesktopAppExtension /// PrometeApp を Vulkan デスクトップアプリケーションとして構築します。 /// /// - /// 実験的なバックエンドです。現在はウィンドウ表示とクリアカラー描画のみをサポートし、 - /// ノードの描画は行われません。詳細は VULKAN_PORTING_PLAN.md を参照してください。 + /// 実験的なバックエンドです。スプライト・プリミティブ・トリム・RenderTexture の描画に対応しています。 + /// カスタムシェーダー・マスク・扇形テクスチャ・ポストプロセスは未対応です。 + /// 詳細は VULKAN_PORTING_PLAN.md を参照してください。 /// /// PrometeAppのビルダー /// ウィンドウの設定 diff --git a/VULKAN_PORTING_PLAN.md b/VULKAN_PORTING_PLAN.md index f5e5526..0513fdf 100644 --- a/VULKAN_PORTING_PLAN.md +++ b/VULKAN_PORTING_PLAN.md @@ -75,7 +75,12 @@ OpenGL 依存コードは以下の 16 ファイル・約 2,600 行に限定さ - `VulkanDesktop/VulkanDesktopAppExtension.BuildWithVulkanDesktop()` - 到達点: **クリアカラーの表示とリサイズ対応**。TextureFactory / ShaderFactory / RenderTextureProvider / ScreenBlitter は暫定的に Headless 実装を流用(ランナー未登録のため描画コマンドは無視される) -### Phase 2: リソース基盤(規模目安: 1,500 行) +### Phase 2: リソース基盤 🚧 大部分実装済み + +実装済み: `VulkanResourceManager` (int ID テーブル + staging アップロード + ディスクリプタ管理)、`VulkanTextureFactory`、`VulkanRenderTextureProvider` (パス中断/再開・Resize 対応)、`VulkanPipelineProvider` (パイプラインキャッシュ)、shaderc ランタイムコンパイル (`Silk.NET.Shaderc`)。標準シェーダーは Vulkan GLSL 450 版を実行時コンパイル(事前 SPIR-V 化は将来最適化)。 +未実装: カスタムシェーダー (`VulkanShaderFactory` は NotSupportedException)。 + +元の計画(規模目安: 1,500 行): - `VulkanTextureFactory`: staging buffer 経由アップロード、Nearest サンプラー、int ID リソーステーブル、スプライトシート(アトラス + UV、ハーフテクセルインセットは既存の共通ロジックを再利用) - `VulkanShaderFactory`: shaderc による GLSL 450 → SPIR-V コンパイル + リフレクション結果のキャッシュ @@ -83,7 +88,12 @@ OpenGL 依存コードは以下の 16 ファイル・約 2,600 行に限定さ - `VulkanRenderTextureProvider`: オフスクリーンイメージ + レンダーパス、`BeginCapture` のパス中断/再開、`Resize` - 標準シェーダーの Vulkan GLSL 450 版を作成し、ビルド時 SPIR-V 化(MSBuild ターゲット) -### Phase 3: ランナー移植(規模目安: 2,000 行) +### Phase 3: ランナー移植 🚧 主要部分実装済み + +実装済み: `VulkanDrawTextureBatchedCommandRunner` (インスタンシング + per-frame アリーナ)、`VulkanDrawPrimitiveCommandRunner`、`VulkanBeginTrim/EndTrimCommandRunner`、`VulkanScreenBlitter` (単純ブリット)。スクリーンショット (`TakeScreenshot`/`SaveScreenshotAsync`) も実装済みで、Promete.Experimental.Vulkan によるピクセル単位の自動検証がパスしている。 +未実装: `DrawPieTextureCommand`、マスク系 (stencil/alpha)、ポストプロセスマテリアル、カスタムマテリアル、線幅 >1 の線 (wideLines)。 + +元の計画(規模目安: 2,000 行): 移植順(依存が少なく検証しやすい順): From 7ab366b2ccb389b906ad9788f790efe7113bc03c Mon Sep 17 00:00:00 2001 From: Ebise Lutica <7106976+EbiseLutica@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:01:03 +0900 Subject: [PATCH 07/16] =?UTF-8?q?fix(Vulkan):=20FrameBuffer=E3=81=AE?= =?UTF-8?q?=E3=83=86=E3=82=AF=E3=82=B9=E3=83=81=E3=83=A3=E3=81=8C=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E5=8F=8D=E8=BB=A2=E3=81=99=E3=82=8B=E5=95=8F=E9=A1=8C?= =?UTF-8?q?=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FrameBuffer は GL の RT テクスチャが bottom-up 格納であることを前提に 子ノードを上下反転して補正しているが、Vulkan の RT は top-down のため 二重反転になっていた。RenderTexture 用の Texture2D に V 反転 UV を 持たせ、GL と同じ見え方に統一する。 検証: Experimental に FrameBuffer の上下向きピクセル検証を追加 (8項目全パス) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016A9ZxvQBn4vAu8Erah3rHr --- Promete.Example/Program.cs | 5 +++-- Promete.Experimental.Vulkan/MainScene.cs | 10 ++++++++++ .../Vulkan/VulkanRenderTextureProvider.cs | 14 +++++++++++--- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/Promete.Example/Program.cs b/Promete.Example/Program.cs index 3eca41b..6af506c 100644 --- a/Promete.Example/Program.cs +++ b/Promete.Example/Program.cs @@ -1,10 +1,11 @@ using Promete; using Promete.Coroutines; using Promete.Example; -using Promete.GLDesktop; +using Promete.VulkanDesktop; using Promete.ImGui; using Promete.Input; using Promete.Windowing; +using Promete.GLDesktop; var app = PrometeApp .Create() @@ -13,7 +14,7 @@ .Use() .Use() .Use() - .Use() + // .Use() .BuildWithOpenGLDesktop( WindowOptions.Default with { diff --git a/Promete.Experimental.Vulkan/MainScene.cs b/Promete.Experimental.Vulkan/MainScene.cs index b09262a..4b6b128 100644 --- a/Promete.Experimental.Vulkan/MainScene.cs +++ b/Promete.Experimental.Vulkan/MainScene.cs @@ -21,6 +21,7 @@ public class MainScene : Scene ); private Texture2D _redTexture; + private FrameBuffer? _frameBuffer; private int _frameCount; private bool _screenshotRequested; @@ -41,6 +42,12 @@ public override void OnStart() tinted.TintColor = Color.Blue; Root.Add(tinted); + // FrameBuffer の上下向き検証: 黄背景 100x100 の上部 30px にマゼンタ帯 + _frameBuffer = new FrameBuffer(100, 100) { BackgroundColor = Color.Yellow }; + var magenta = App.TextureFactory.CreateSolid(Color.Magenta, (100, 30)); + _frameBuffer.Add(new Sprite(magenta).Location(0, 0)); + Root.Add(new Sprite(_frameBuffer.Texture).Location(50, 300)); + Console.WriteLine("[MainScene] OnStart: ノード配置完了"); } @@ -57,6 +64,7 @@ public override void OnUpdate() public override void OnDestroy() { + _frameBuffer?.Dispose(); _redTexture.Dispose(); Console.WriteLine("[MainScene] OnDestroy"); } @@ -76,6 +84,8 @@ private async Task VerifyAndExitAsync() failures += Verify(img, 475, 75, Color.Blue, "青ティントスプライト"); failures += Verify(img, 100, 400, Color.DarkSlateBlue, "背景 (下部, Y軸反転検出)"); failures += Verify(img, 620, 460, Color.DarkSlateBlue, "背景 (右下)"); + failures += Verify(img, 100, 310, Color.Magenta, "FrameBuffer 上部 (マゼンタ帯)"); + failures += Verify(img, 100, 380, Color.Yellow, "FrameBuffer 下部 (黄背景)"); Console.WriteLine( failures == 0 diff --git a/Promete/Graphics/Rendering/Vulkan/VulkanRenderTextureProvider.cs b/Promete/Graphics/Rendering/Vulkan/VulkanRenderTextureProvider.cs index f216d6b..b2ba9df 100644 --- a/Promete/Graphics/Rendering/Vulkan/VulkanRenderTextureProvider.cs +++ b/Promete/Graphics/Rendering/Vulkan/VulkanRenderTextureProvider.cs @@ -18,8 +18,7 @@ VulkanResourceManager resources public RenderTexture Create(VectorInt size) { var target = CreateTarget(size, textureId: null); - var texture = new Texture2D(target.TextureId, size, _ => { }); - var rt = new RenderTexture(size, texture, this); + var rt = new RenderTexture(size, CreateFlippedTexture(target.TextureId, size), this); _targets[rt] = target; return rt; } @@ -48,9 +47,18 @@ public void Resize(RenderTexture renderTexture, VectorInt newSize) target.Framebuffer = newTarget.Framebuffer; target.Extent = newTarget.Extent; - renderTexture.Texture = new Texture2D(target.TextureId, newSize, _ => { }); + renderTexture.Texture = CreateFlippedTexture(target.TextureId, newSize); } + /// + /// V 反転 UV を持つ RenderTexture 用の を生成します。 + /// GL の RT テクスチャは bottom-up 格納であり、 等はそれを前提に + /// 子ノードを上下反転して補正しています。Vulkan の RT は top-down のため、 + /// UV を V 反転させることで GL と同じ見え方に揃えます。 + /// + private static Texture2D CreateFlippedTexture(int textureId, VectorInt size) => + new(textureId, size, _ => { }, new Vector(0, 1), new Vector(1, 0)); + public void Release(RenderTexture renderTexture) { if (!_targets.Remove(renderTexture, out var target)) From 0889a4e4bf8d23bf3ca423e38240abdbec0b8632 Mon Sep 17 00:00:00 2001 From: Ebise Lutica <7106976+EbiseLutica@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:03:18 +0900 Subject: [PATCH 08/16] =?UTF-8?q?feat(Example):=20--vulkan=20=E3=83=95?= =?UTF-8?q?=E3=83=A9=E3=82=B0=E3=81=A7Vulkan=E3=83=90=E3=83=83=E3=82=AF?= =?UTF-8?q?=E3=82=A8=E3=83=B3=E3=83=89=E3=82=92=E9=81=B8=E6=8A=9E=E3=81=A7?= =?UTF-8?q?=E3=81=8D=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dotnet run --project Promete.Example -- --vulkan で実験的な Vulkanバックエンドを試せる。ImGuiプラグインはOpenGL専用のため Vulkan時は無効化する。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016A9ZxvQBn4vAu8Erah3rHr --- Promete.Example/Program.cs | 39 +++++++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/Promete.Example/Program.cs b/Promete.Example/Program.cs index 6af506c..4853fab 100644 --- a/Promete.Example/Program.cs +++ b/Promete.Example/Program.cs @@ -1,29 +1,38 @@ using Promete; using Promete.Coroutines; using Promete.Example; -using Promete.VulkanDesktop; +using Promete.GLDesktop; using Promete.ImGui; using Promete.Input; +using Promete.VulkanDesktop; using Promete.Windowing; -using Promete.GLDesktop; -var app = PrometeApp +// --vulkan フラグで実験的な Vulkan バックエンドを使用する +// (ImGui プラグインは OpenGL 専用のため Vulkan 時は無効化) +var useVulkan = args.Contains("--vulkan"); + +var builder = PrometeApp .Create() .Use() .Use() .Use() .Use() - .Use() - // .Use() - .BuildWithOpenGLDesktop( - WindowOptions.Default with - { - Title = "Promete Demo", - Mode = WindowMode.Resizable, - TargetFps = 0, - TargetUps = 0, - IsVsyncMode = false, - } - ); + .Use(); + +if (!useVulkan) + builder = builder.Use(); + +var options = WindowOptions.Default with +{ + Title = useVulkan ? "Promete Demo (Vulkan)" : "Promete Demo", + Mode = WindowMode.Resizable, + TargetFps = 0, + TargetUps = 0, + IsVsyncMode = false, +}; + +var app = useVulkan + ? builder.BuildWithVulkanDesktop(options) + : builder.BuildWithOpenGLDesktop(options); return app.Run(); From 9ec9599db3632d0db38e24765999cd86b51d75c9 Mon Sep 17 00:00:00 2001 From: Ebise Lutica <7106976+EbiseLutica@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:18:59 +0900 Subject: [PATCH 09/16] =?UTF-8?q?feat(Vulkan):=20=E3=82=AB=E3=82=B9?= =?UTF-8?q?=E3=82=BF=E3=83=A0=E3=82=B7=E3=82=A7=E3=83=BC=E3=83=80=E3=83=BC?= =?UTF-8?q?=E3=83=BBPieSprite=E3=83=BB=E3=83=9D=E3=82=B9=E3=83=88=E3=83=97?= =?UTF-8?q?=E3=83=AD=E3=82=BB=E3=82=B9=E3=81=AB=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SpirvReflector: SPIR-Vから uniform ブロックのメンバー名/オフセットを抽出する 最小リフレクタ (追加ネイティブ依存なし) - VulkanShaderManager / VulkanShaderFactory: GLSL450カスタムシェーダーの コンパイルと ShaderProgram.Handle 互換のIDテーブル管理 - VulkanMaterialSystem: Material の名前ベース Uniform をリフレクション結果に 基づき per-material UBO (set=1, binding=0) へ書き込み - スプライトのカスタムマテリアル描画パス (texture_instanced 互換レイアウト規約) - VulkanDrawPieTextureCommandRunner: push constant (MVP/tint/角度) で扇形描画 - VulkanScreenBlitter: ピンポンバッファによるポストプロセスチェーン - スクリーンショットをポストプロセス適用後の最終ブリット元から読むよう変更 - Experimental: 検証を13項目に拡張 (PieSprite/カスタムマテリアル/色反転 ポストプロセスの2フェーズ検証) — 全パス Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016A9ZxvQBn4vAu8Erah3rHr --- Promete.Experimental.Vulkan/MainScene.cs | 183 +++++++-- .../Backends/Vulkan/VulkanDesktopBackend.cs | 25 +- .../Backends/Vulkan/VulkanDesktopGameView.cs | 4 +- .../VulkanDrawPieTextureCommandRunner.cs | 183 +++++++++ .../VulkanDrawTextureBatchedCommandRunner.cs | 39 +- .../Rendering/Vulkan/SpirvReflector.cs | 148 +++++++ .../Rendering/Vulkan/VulkanMaterialSystem.cs | 247 ++++++++++++ .../Vulkan/VulkanPipelineProvider.cs | 361 +++++++++++++----- .../Rendering/Vulkan/VulkanScreenBlitter.cs | 100 ++++- .../Rendering/Vulkan/VulkanShaderFactory.cs | 38 +- .../Rendering/Vulkan/VulkanShaderManager.cs | 151 ++++++++ Promete/Resources/shaders/vulkan/pie.frag | 43 +++ Promete/Resources/shaders/vulkan/pie.vert | 18 + VULKAN_PORTING_PLAN.md | 9 +- 14 files changed, 1387 insertions(+), 162 deletions(-) create mode 100644 Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawPieTextureCommandRunner.cs create mode 100644 Promete/Graphics/Rendering/Vulkan/SpirvReflector.cs create mode 100644 Promete/Graphics/Rendering/Vulkan/VulkanMaterialSystem.cs create mode 100644 Promete/Graphics/Rendering/Vulkan/VulkanShaderManager.cs create mode 100644 Promete/Resources/shaders/vulkan/pie.frag create mode 100644 Promete/Resources/shaders/vulkan/pie.vert diff --git a/Promete.Experimental.Vulkan/MainScene.cs b/Promete.Experimental.Vulkan/MainScene.cs index 4b6b128..668a6db 100644 --- a/Promete.Experimental.Vulkan/MainScene.cs +++ b/Promete.Experimental.Vulkan/MainScene.cs @@ -1,29 +1,92 @@ using System.Drawing; +using System.Numerics; using Promete.Graphics; using Promete.Nodes; -using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using Color = System.Drawing.Color; +using ImageSharpImage = SixLabors.ImageSharp.Image; +using Rgba32Image = SixLabors.ImageSharp.Image; namespace Promete.Experimental.Vulkan; /// /// Vulkan バックエンドの描画検証シーン。 -/// スプライトとプリミティブを描画し、スクリーンショットのピクセル色を検証して終了します。 +/// フェーズ1: スプライト・プリミティブ・FrameBuffer・PieSprite・カスタムマテリアルを検証。 +/// フェーズ2: ポストプロセス (色反転) を適用して検証し、終了します。 /// public class MainScene : Scene { - private const int ScreenshotFrame = 60; + private const int Phase1Frame = 60; + private const int Phase2Frame = 120; + + private static readonly string ScreenshotPath1 = Path.Combine(AppContext.BaseDirectory, "vulkan_test.png"); + private static readonly string ScreenshotPath2 = Path.Combine(AppContext.BaseDirectory, "vulkan_test_postprocess.png"); + + private const string InstancedVertexShader = """ + #version 450 + layout(location = 0) in vec2 vPos; + layout(location = 1) in vec2 vUv; + layout(location = 2) in vec4 iModel0; + layout(location = 3) in vec4 iModel1; + layout(location = 4) in vec4 iModel2; + layout(location = 5) in vec4 iModel3; + layout(location = 6) in vec4 iTintColor; + layout(location = 7) in vec4 iUvRect; + layout(location = 0) out vec2 fUv; + layout(location = 1) out vec4 fTintColor; + layout(push_constant) uniform PushConstants { mat4 uProjection; }; + void main() + { + mat4 model = mat4(iModel0, iModel1, iModel2, iModel3); + gl_Position = uProjection * model * vec4(vPos, 0.0, 1.0); + fUv = mix(iUvRect.xy, iUvRect.zw, vUv); + fTintColor = iTintColor; + } + """; + + private const string OverrideColorFragmentShader = """ + #version 450 + layout(location = 0) in vec2 fUv; + layout(location = 1) in vec4 fTintColor; + layout(set = 0, binding = 0) uniform sampler2D uTexture0; + layout(set = 1, binding = 0) uniform Uniforms { vec4 uOverrideColor; }; + layout(location = 0) out vec4 FragColor; + void main() + { + FragColor = uOverrideColor; + } + """; - private static readonly string ScreenshotPath = Path.Combine( - AppContext.BaseDirectory, - "vulkan_test.png" - ); + private const string BlitVertexShader = """ + #version 450 + layout(location = 0) out vec2 fUv; + void main() + { + vec2 pos = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2); + fUv = pos; + gl_Position = vec4(pos * 2.0 - 1.0, 0.0, 1.0); + } + """; + + private const string InvertFragmentShader = """ + #version 450 + layout(location = 0) in vec2 fUv; + layout(set = 0, binding = 0) uniform sampler2D uScreenTexture; + layout(location = 0) out vec4 FragColor; + void main() + { + vec4 c = texture(uScreenTexture, fUv); + FragColor = vec4(1.0 - c.rgb, 1.0); + } + """; private Texture2D _redTexture; private FrameBuffer? _frameBuffer; + private ShaderProgram? _overrideShader; + private ShaderProgram? _invertShader; private int _frameCount; - private bool _screenshotRequested; + private int _phase; + private int _failures; public override void OnStart() { @@ -48,60 +111,118 @@ public override void OnStart() _frameBuffer.Add(new Sprite(magenta).Location(0, 0)); Root.Add(new Sprite(_frameBuffer.Texture).Location(50, 300)); - Console.WriteLine("[MainScene] OnStart: ノード配置完了"); + // PieSprite 検証: シアン 100x100、0% から 25% (12時→3時の扇形) + var cyan = App.TextureFactory.CreateSolid(Color.Cyan, (100, 100)); + var pie = new PieSprite(cyan) { StartPercent = 0, Percent = 25 }; + pie.Location = (250, 300); + Root.Add(pie); + + // カスタムマテリアル検証: UBO の uOverrideColor で塗りつぶすシェーダー + _overrideShader = ShaderProgram + .Create() + .Vertex(InstancedVertexShader) + .Fragment(OverrideColorFragmentShader) + .Compile(); + var material = new Material(_overrideShader); + material["uOverrideColor"] = new Vector4(1f, 0.4f, 0f, 1f); // (255, 102, 0) + var customSprite = new Sprite(white).Location(450, 150); + customSprite.Material = material; + Root.Add(customSprite); + + // フェーズ2用: 色反転ポストプロセスシェーダー + _invertShader = ShaderProgram + .Create() + .Vertex(BlitVertexShader) + .Fragment(InvertFragmentShader) + .Compile(); + + Console.WriteLine("[MainScene] OnStart: ノード配置・シェーダーコンパイル完了"); } public override void OnUpdate() { _frameCount++; - if (_frameCount == ScreenshotFrame && !_screenshotRequested) + if (_frameCount == Phase1Frame && _phase == 0) + { + _phase = 1; + _ = RunPhase1Async(); + } + + if (_frameCount == Phase2Frame && _phase == 1) { - _screenshotRequested = true; - _ = VerifyAndExitAsync(); + _phase = 2; + _ = RunPhase2Async(); } } public override void OnDestroy() { _frameBuffer?.Dispose(); + _overrideShader?.Dispose(); + _invertShader?.Dispose(); _redTexture.Dispose(); Console.WriteLine("[MainScene] OnDestroy"); } - private async Task VerifyAndExitAsync() + private async Task RunPhase1Async() { try { - await Window.SaveScreenshotAsync(ScreenshotPath); - Console.WriteLine($"[MainScene] スクリーンショット保存: {ScreenshotPath}"); - - using var img = SixLabors.ImageSharp.Image.Load(ScreenshotPath); - var failures = 0; - failures += Verify(img, 10, 10, Color.DarkSlateBlue, "背景 (左上)"); - failures += Verify(img, 100, 100, Color.Red, "赤スプライト中心"); - failures += Verify(img, 300, 150, Color.Lime, "ライム矩形中心"); - failures += Verify(img, 475, 75, Color.Blue, "青ティントスプライト"); - failures += Verify(img, 100, 400, Color.DarkSlateBlue, "背景 (下部, Y軸反転検出)"); - failures += Verify(img, 620, 460, Color.DarkSlateBlue, "背景 (右下)"); - failures += Verify(img, 100, 310, Color.Magenta, "FrameBuffer 上部 (マゼンタ帯)"); - failures += Verify(img, 100, 380, Color.Yellow, "FrameBuffer 下部 (黄背景)"); + await Window.SaveScreenshotAsync(ScreenshotPath1); + using var img = ImageSharpImage.Load(ScreenshotPath1); + + Console.WriteLine("[MainScene] --- フェーズ1: 通常描画 ---"); + _failures += Verify(img, 10, 10, Color.DarkSlateBlue, "背景 (左上)"); + _failures += Verify(img, 100, 100, Color.Red, "赤スプライト中心"); + _failures += Verify(img, 300, 150, Color.Lime, "ライム矩形中心"); + _failures += Verify(img, 475, 75, Color.Blue, "青ティントスプライト"); + _failures += Verify(img, 620, 460, Color.DarkSlateBlue, "背景 (右下)"); + _failures += Verify(img, 100, 310, Color.Magenta, "FrameBuffer 上部 (マゼンタ帯)"); + _failures += Verify(img, 100, 380, Color.Yellow, "FrameBuffer 下部 (黄背景)"); + _failures += Verify(img, 320, 330, Color.Cyan, "PieSprite 右上 1/4 (シアン)"); + _failures += Verify(img, 280, 370, Color.DarkSlateBlue, "PieSprite 左下 (背景=切り抜き)"); + _failures += Verify(img, 475, 175, Color.FromArgb(255, 102, 0), "カスタムマテリアル (uOverrideColor)"); + + // フェーズ2: 色反転ポストプロセスを適用 + App.PostProcessMaterials.Add(new Material(_invertShader!)); + Console.WriteLine("[MainScene] ポストプロセス (色反転) を適用"); + } + catch (Exception ex) + { + Console.WriteLine($"[MainScene] ❌ フェーズ1で例外: {ex}"); + App.Exit(2); + } + } + + private async Task RunPhase2Async() + { + try + { + await Window.SaveScreenshotAsync(ScreenshotPath2); + using var img = ImageSharpImage.Load(ScreenshotPath2); + + Console.WriteLine("[MainScene] --- フェーズ2: ポストプロセス (色反転) ---"); + var invBg = Color.FromArgb(255 - 72, 255 - 61, 255 - 139); + _failures += Verify(img, 10, 10, invBg, "背景 反転"); + _failures += Verify(img, 100, 100, Color.Cyan, "赤スプライト 反転 (シアン)"); + _failures += Verify(img, 300, 150, Color.Magenta, "ライム矩形 反転 (マゼンタ)"); Console.WriteLine( - failures == 0 + _failures == 0 ? "[MainScene] ✅ 全ピクセル検証パス" - : $"[MainScene] ❌ {failures} 件の検証失敗" + : $"[MainScene] ❌ {_failures} 件の検証失敗" ); - App.Exit(failures == 0 ? 0 : 1); + App.Exit(_failures == 0 ? 0 : 1); } catch (Exception ex) { - Console.WriteLine($"[MainScene] ❌ 検証中に例外: {ex}"); + Console.WriteLine($"[MainScene] ❌ フェーズ2で例外: {ex}"); App.Exit(2); } } - private static int Verify(Image img, int x, int y, Color expected, string label) + private static int Verify(Rgba32Image img, int x, int y, Color expected, string label) { var actual = img[x, y]; var ok = diff --git a/Promete/Backends/Vulkan/VulkanDesktopBackend.cs b/Promete/Backends/Vulkan/VulkanDesktopBackend.cs index 2001491..a698897 100644 --- a/Promete/Backends/Vulkan/VulkanDesktopBackend.cs +++ b/Promete/Backends/Vulkan/VulkanDesktopBackend.cs @@ -28,11 +28,14 @@ public class VulkanDesktopBackend : BackendBase private VulkanDesktopGameView _gameView = null!; private VulkanContext _context = null!; private VulkanResourceManager _resources = null!; + private VulkanShaderManager _shaderManager = null!; + private VulkanMaterialSystem _materialSystem = null!; private VulkanPipelineProvider _pipelines = null!; private VulkanTextureFactory _textureFactory = null!; private VulkanRenderTextureProvider _renderTextureProvider = null!; private VulkanScreenBlitter _screenBlitter = null!; private VulkanDrawTextureBatchedCommandRunner? _textureRunner; + private VulkanDrawPieTextureCommandRunner? _pieRunner; public override void OnInitialize(PrometeApp app, WindowOptions opts) { @@ -62,7 +65,9 @@ public override void OnInitialize(PrometeApp app, WindowOptions opts) _context = new VulkanContext(_nativeWindow); _resources = new VulkanResourceManager(_context); - _pipelines = new VulkanPipelineProvider(_context, _resources); + _shaderManager = new VulkanShaderManager(_context); + _materialSystem = new VulkanMaterialSystem(_context, _shaderManager); + _pipelines = new VulkanPipelineProvider(_context, _resources, _shaderManager, _materialSystem); _time = new SilkNetCommonTimeProvider(_nativeWindow); _gameView = new VulkanDesktopGameView(_app, _nativeWindow); _textureFactory = new VulkanTextureFactory(_app, _resources); @@ -72,6 +77,8 @@ public override void OnInitialize(PrometeApp app, WindowOptions opts) _resources, _pipelines, _renderTextureProvider, + _shaderManager, + _materialSystem, _gameView ); _gameView.AttachRenderingResources( @@ -94,7 +101,8 @@ public override void OnInitialize(PrometeApp app, WindowOptions opts) public override IRenderTextureProvider SetupRenderTextureProvider() => _renderTextureProvider; - public override IShaderFactory SetupShaderFactory() => new VulkanShaderFactory(); + public override IShaderFactory SetupShaderFactory() => + new VulkanShaderFactory(_shaderManager, _pipelines); public override void OnStart(PrometeApp app) { @@ -112,10 +120,18 @@ private void OnLoad() _screenBlitter.InitializeScreenRenderTexture(); // ランナーをコマンドキューへ登録する - _textureRunner = new VulkanDrawTextureBatchedCommandRunner(_context, _resources, _pipelines); + _textureRunner = new VulkanDrawTextureBatchedCommandRunner( + _context, + _resources, + _pipelines, + _shaderManager, + _materialSystem + ); + _pieRunner = new VulkanDrawPieTextureCommandRunner(_context, _resources, _pipelines); _app.GetPlugin() .RegisterRunnerRange( _textureRunner, + _pieRunner, new VulkanDrawPrimitiveCommandRunner(_context, _pipelines), new VulkanBeginTrimCommandRunner(_context), new VulkanEndTrimCommandRunner(_context) @@ -130,7 +146,10 @@ private void OnClosing() return; _context.WaitIdle(); _textureRunner?.Dispose(); + _pieRunner?.Dispose(); _pipelines.Dispose(); + _materialSystem.Dispose(); + _shaderManager.Dispose(); _resources.Dispose(); _context.Dispose(); } diff --git a/Promete/Backends/Vulkan/VulkanDesktopGameView.cs b/Promete/Backends/Vulkan/VulkanDesktopGameView.cs index 163a58e..d200810 100644 --- a/Promete/Backends/Vulkan/VulkanDesktopGameView.cs +++ b/Promete/Backends/Vulkan/VulkanDesktopGameView.cs @@ -202,7 +202,9 @@ private void EnsureRenderingResources() private Image TakeScreenshotAsImage() { - var target = _renderTextureProvider!.GetTarget(_screenBlitter!.ScreenRenderTexture); + // ポストプロセス適用後の最終ブリット元を読み出す(表示内容と一致させる) + var source = _screenBlitter!.LastBlitSource ?? _screenBlitter.ScreenRenderTexture; + var target = _renderTextureProvider!.GetTarget(source); var pixels = _context!.ReadImagePixels(target.Image, target.Extent.Width, target.Extent.Height); return Image.LoadPixelData(pixels, (int)target.Extent.Width, (int)target.Extent.Height); } diff --git a/Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawPieTextureCommandRunner.cs b/Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawPieTextureCommandRunner.cs new file mode 100644 index 0000000..37b10a1 --- /dev/null +++ b/Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawPieTextureCommandRunner.cs @@ -0,0 +1,183 @@ +using System; +using System.Drawing; +using System.Numerics; +using Promete.Graphics.Rendering.Commands; +using Promete.Internal; +using Silk.NET.Vulkan; +using Buffer = Silk.NET.Vulkan.Buffer; + +namespace Promete.Graphics.Rendering.Vulkan.Runners; + +/// +/// で扇形テクスチャを描画するランナーです。 +/// +internal sealed unsafe class VulkanDrawPieTextureCommandRunner( + VulkanContext ctx, + VulkanResourceManager resources, + VulkanPipelineProvider pipelines +) : CommandRunner, IDisposable +{ + // push constant: mat4 (16) + vec4 tint (4) + vec2 angles (2) + padding (2) = 24 floats + private const int PushConstantFloats = 24; + + private Buffer _quadVbo; + private DeviceMemory _quadVboMemory; + private Buffer _quadEbo; + private DeviceMemory _quadEboMemory; + private bool _initialized; + private bool _materialWarned; + private bool _disposed; + + public override void Execute(DrawPieTextureCommand command) + { + Draw( + command.Texture, + command.ModelMatrix, + command.TintColor, + command.Width, + command.Height, + command.StartPercent, + command.Percent, + command.Material + ); + } + + public void Dispose() + { + if (_disposed || !_initialized) + return; + _disposed = true; + var vk = ctx.Vk; + vk.DestroyBuffer(ctx.Device, _quadVbo, null); + vk.FreeMemory(ctx.Device, _quadVboMemory, null); + vk.DestroyBuffer(ctx.Device, _quadEbo, null); + vk.FreeMemory(ctx.Device, _quadEboMemory, null); + } + + private void Draw( + Texture2D texture, + Matrix4x4 modelMatrix, + Color color, + float width, + float height, + float startPercent, + float percent, + Material? material + ) + { + PrometeApp.Current.ThrowIfNotMainThread(); + if (!ctx.IsFrameActive || !resources.Contains(texture.Handle)) + return; + + // TODO: カスタムマテリアルの適用に対応する + if (material is not null && !_materialWarned) + { + _materialWarned = true; + LogHelper.Bug("Vulkan バックエンドはまだ PieSprite のカスタムマテリアルをサポートしていません。"); + } + + EnsureInitialized(); + + var model = + Matrix4x4.CreateScale(width, height, 1) + * modelMatrix; + + var extent = ctx.CurrentTargetExtent; + var projection = Matrix4x4.CreateOrthographicOffCenter( + 0, + extent.Width, + 0, + extent.Height, + -1f, + 1f + ); + var mvp = model * projection; + + // パーセント→ラジアン変換(12時方向を0%にするため-90度オフセット) + var startAngle = ((startPercent / 100.0f * 360.0f) - 90.0f) * MathF.PI / 180.0f; + var endAngle = ((percent / 100.0f * 360.0f) - 90.0f) * MathF.PI / 180.0f; + + var push = stackalloc float[PushConstantFloats]; + *(Matrix4x4*)push = mvp; + push[16] = color.R / 255f; + push[17] = color.G / 255f; + push[18] = color.B / 255f; + push[19] = color.A / 255f; + push[20] = startAngle; + push[21] = endAngle; + + var vk = ctx.Vk; + var cmd = ctx.CurrentCommandBuffer; + + var pipeline = pipelines.GetPiePipeline(VulkanPipelineProvider.PassClass.Offscreen); + vk.CmdBindPipeline(cmd, PipelineBindPoint.Graphics, pipeline); + vk.CmdPushConstants( + cmd, + pipelines.PieLayout, + ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit, + 0, + PushConstantFloats * sizeof(float), + push + ); + + var descriptorSet = resources.GetDescriptorSet(texture.Handle); + vk.CmdBindDescriptorSets( + cmd, + PipelineBindPoint.Graphics, + pipelines.PieLayout, + 0, + 1, + in descriptorSet, + 0, + null + ); + + var offset = 0ul; + vk.CmdBindVertexBuffers(cmd, 0, 1, in _quadVbo, in offset); + vk.CmdBindIndexBuffer(cmd, _quadEbo, 0, IndexType.Uint32); + vk.CmdDrawIndexed(cmd, 6, 1, 0, 0, 0); + } + + private void EnsureInitialized() + { + if (_initialized) + return; + + Span vertices = + [ + 1.0f, 0.0f, 1.0f, 0.0f, // 右上 + 1.0f, 1.0f, 1.0f, 1.0f, // 右下 + 0.0f, 1.0f, 0.0f, 1.0f, // 左下 + 0.0f, 0.0f, 0.0f, 0.0f, // 左上 + ]; + Span indices = [0, 1, 3, 1, 2, 3]; + + (_quadVbo, _quadVboMemory) = CreateStaticBuffer(vertices, BufferUsageFlags.VertexBufferBit); + (_quadEbo, _quadEboMemory) = CreateStaticBuffer(indices, BufferUsageFlags.IndexBufferBit); + _initialized = true; + } + + private (Buffer Buffer, DeviceMemory Memory) CreateStaticBuffer( + ReadOnlySpan data, + BufferUsageFlags usage + ) + where T : unmanaged + { + var size = (ulong)(data.Length * sizeof(T)); + var (buffer, memory) = ctx.CreateBuffer( + size, + usage, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit + ); + + void* mapped; + ctx.Vk.MapMemory(ctx.Device, memory, 0, size, 0, &mapped); + fixed (T* src = data) + { + System.Buffer.MemoryCopy(src, mapped, size, size); + } + + ctx.Vk.UnmapMemory(ctx.Device, memory); + return (buffer, memory); + } +} diff --git a/Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawTextureBatchedCommandRunner.cs b/Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawTextureBatchedCommandRunner.cs index ccbcd1c..fd84db7 100644 --- a/Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawTextureBatchedCommandRunner.cs +++ b/Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawTextureBatchedCommandRunner.cs @@ -23,6 +23,8 @@ internal sealed unsafe class VulkanDrawTextureBatchedCommandRunner private readonly VulkanContext _ctx; private readonly VulkanResourceManager _resources; private readonly VulkanPipelineProvider _pipelines; + private readonly VulkanShaderManager _shaders; + private readonly VulkanMaterialSystem _materials; private float[] _instanceData = new float[InitialInstanceCapacity * InstanceStride]; private Buffer _quadVbo; @@ -36,12 +38,16 @@ internal sealed unsafe class VulkanDrawTextureBatchedCommandRunner public VulkanDrawTextureBatchedCommandRunner( VulkanContext ctx, VulkanResourceManager resources, - VulkanPipelineProvider pipelines + VulkanPipelineProvider pipelines, + VulkanShaderManager shaders, + VulkanMaterialSystem materials ) { _ctx = ctx; _resources = resources; _pipelines = pipelines; + _shaders = shaders; + _materials = materials; } public override void Execute(DrawTextureBatchedCommand command) @@ -71,11 +77,14 @@ private void DrawInstanced(List items, Material? material) if (!_resources.Contains(textureId)) return; - // TODO: Phase 3+ でカスタムマテリアルに対応する - if (material is not null && !_materialWarned) + // カスタムマテリアル: シェーダーがコンパイル済みならカスタムパイプラインを使用 + var useCustom = material is not null && _shaders.Contains(material.Shader.Handle); + if (material is not null && !useCustom && !_materialWarned) { _materialWarned = true; - LogHelper.Bug("Vulkan バックエンドはまだカスタムマテリアルをサポートしていません。"); + LogHelper.Bug( + "Material のシェーダーがコンパイルされていないため、デフォルトシェーダーで描画します。" + ); } EnsureInitialized(); @@ -130,7 +139,13 @@ private void DrawInstanced(List items, Material? material) var vk = _ctx.Vk; var cmdBuffer = _ctx.CurrentCommandBuffer; - var pipeline = _pipelines.GetTexturePipeline(VulkanPipelineProvider.PassClass.Offscreen); + var pipeline = useCustom + ? _pipelines.GetCustomSpritePipeline( + material!.Shader.Handle, + VulkanPipelineProvider.PassClass.Offscreen + ) + : _pipelines.GetTexturePipeline(VulkanPipelineProvider.PassClass.Offscreen); + var layout = useCustom ? _pipelines.CustomSpriteLayout : _pipelines.TextureLayout; vk.CmdBindPipeline(cmdBuffer, PipelineBindPoint.Graphics, pipeline); // プロジェクション行列 (Vulkan は NDC が Y 下向きなので bottom=0, top=height) @@ -143,20 +158,13 @@ private void DrawInstanced(List items, Material? material) -1f, 1f ); - vk.CmdPushConstants( - cmdBuffer, - _pipelines.TextureLayout, - ShaderStageFlags.VertexBit, - 0, - 64, - &projection - ); + vk.CmdPushConstants(cmdBuffer, layout, ShaderStageFlags.VertexBit, 0, 64, &projection); var descriptorSet = _resources.GetDescriptorSet(textureId); vk.CmdBindDescriptorSets( cmdBuffer, PipelineBindPoint.Graphics, - _pipelines.TextureLayout, + layout, 0, 1, in descriptorSet, @@ -164,6 +172,9 @@ private void DrawInstanced(List items, Material? material) null ); + if (useCustom) + _materials.Apply(cmdBuffer, material!, layout); + var vertexBuffers = stackalloc Buffer[2] { _quadVbo, instanceBuffer }; var offsets = stackalloc ulong[2] { 0, instanceOffset }; vk.CmdBindVertexBuffers(cmdBuffer, 0, 2, vertexBuffers, offsets); diff --git a/Promete/Graphics/Rendering/Vulkan/SpirvReflector.cs b/Promete/Graphics/Rendering/Vulkan/SpirvReflector.cs new file mode 100644 index 0000000..e686d75 --- /dev/null +++ b/Promete/Graphics/Rendering/Vulkan/SpirvReflector.cs @@ -0,0 +1,148 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Promete.Graphics.Rendering.Vulkan; + +/// +/// SPIR-V バイトコードから Uniform ブロックのレイアウト情報を抽出する最小リフレクタです。 +/// カスタムシェーダーの名前ベース Uniform (Material) をオフセットに解決するために使用します。 +/// +internal static class SpirvReflector +{ + private const uint OpMemberName = 6; + private const uint OpTypeStruct = 30; + private const uint OpTypePointer = 32; + private const uint OpVariable = 59; + private const uint OpDecorate = 71; + private const uint OpMemberDecorate = 72; + + private const uint DecorationBinding = 33; + private const uint DecorationDescriptorSet = 34; + private const uint DecorationOffset = 35; + + private const uint StorageClassUniform = 2; + + /// + /// SPIR-V から Uniform ブロック (storage class Uniform) の一覧を抽出します。 + /// + public static List ReflectUniformBlocks(byte[] spirv) + { + var words = new uint[spirv.Length / 4]; + System.Buffer.BlockCopy(spirv, 0, words, 0, words.Length * 4); + + if (words.Length < 5 || words[0] != 0x07230203) + throw new InvalidOperationException("不正な SPIR-V バイナリです。"); + + // 収集用テーブル + var memberNames = new Dictionary<(uint TypeId, uint Member), string>(); + var memberOffsets = new Dictionary<(uint TypeId, uint Member), uint>(); + var decorations = new Dictionary<(uint Id, uint Decoration), uint>(); + var structTypes = new HashSet(); + var pointerTargets = new Dictionary(); + var uniformVariables = new List<(uint Id, uint PointerTypeId)>(); + + var index = 5; + while (index < words.Length) + { + var opcode = words[index] & 0xFFFF; + var wordCount = (int)(words[index] >> 16); + if (wordCount == 0) + break; + + switch (opcode) + { + case OpMemberName: + memberNames[(words[index + 1], words[index + 2])] = ReadString(words, index + 3, index + wordCount); + break; + case OpMemberDecorate when words[index + 3] == DecorationOffset: + memberOffsets[(words[index + 1], words[index + 2])] = words[index + 4]; + break; + case OpDecorate when wordCount >= 4: + decorations[(words[index + 1], words[index + 2])] = words[index + 3]; + break; + case OpTypeStruct: + structTypes.Add(words[index + 1]); + break; + case OpTypePointer: + pointerTargets[words[index + 1]] = (words[index + 2], words[index + 3]); + break; + case OpVariable when words[index + 3] == StorageClassUniform: + uniformVariables.Add((words[index + 2], words[index + 1])); + break; + } + + index += wordCount; + } + + // Uniform 変数 → 構造体レイアウトを解決 + var blocks = new List(); + foreach (var (variableId, pointerTypeId) in uniformVariables) + { + if (!pointerTargets.TryGetValue(pointerTypeId, out var pointer)) + continue; + if (!structTypes.Contains(pointer.TypeId)) + continue; + + var offsets = new Dictionary(); + uint maxOffset = 0; + foreach (var ((typeId, member), name) in memberNames) + { + if (typeId != pointer.TypeId) + continue; + if (!memberOffsets.TryGetValue((typeId, member), out var offset)) + continue; + offsets[name] = offset; + maxOffset = Math.Max(maxOffset, offset); + } + + blocks.Add( + new UniformBlock + { + Set = decorations.GetValueOrDefault((variableId, DecorationDescriptorSet)), + Binding = decorations.GetValueOrDefault((variableId, DecorationBinding)), + MemberOffsets = offsets, + Size = maxOffset + 64, + } + ); + } + + return blocks; + } + + private static string ReadString(uint[] words, int start, int end) + { + var bytes = new List((end - start) * 4); + for (var i = start; i < end; i++) + { + var word = words[i]; + for (var b = 0; b < 4; b++) + { + var value = (byte)(word >> (b * 8)); + if (value == 0) + return Encoding.UTF8.GetString(bytes.ToArray()); + bytes.Add(value); + } + } + + return Encoding.UTF8.GetString(bytes.ToArray()); + } + + /// + /// リフレクションで得られた Uniform ブロックの情報です。 + /// + public sealed class UniformBlock + { + /// ディスクリプタセット番号。 + public uint Set { get; init; } + + /// バインディング番号。 + public uint Binding { get; init; } + + /// メンバー名 → バイトオフセット。 + public required Dictionary MemberOffsets { get; init; } + + /// ブロックの最低サイズ(最大オフセット + 64 バイトの余裕)。 + public uint Size { get; init; } + } +} diff --git a/Promete/Graphics/Rendering/Vulkan/VulkanMaterialSystem.cs b/Promete/Graphics/Rendering/Vulkan/VulkanMaterialSystem.cs new file mode 100644 index 0000000..509b106 --- /dev/null +++ b/Promete/Graphics/Rendering/Vulkan/VulkanMaterialSystem.cs @@ -0,0 +1,247 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using Promete.Internal; +using Silk.NET.Vulkan; +using Buffer = Silk.NET.Vulkan.Buffer; + +namespace Promete.Graphics.Rendering.Vulkan; + +/// +/// の名前ベース Uniform 値を、リフレクション結果に基づいて +/// per-material の Uniform バッファ (set=1, binding=0) へ書き込み、バインドします。 +/// +internal sealed unsafe class VulkanMaterialSystem : IDisposable +{ + private const uint MaxSets = 1024; + + private readonly VulkanContext _ctx; + private readonly VulkanShaderManager _shaders; + private readonly Dictionary<(Material Material, int Slot), MaterialSlot> _slots = []; + private readonly HashSet _warnedUniforms = []; + + private DescriptorPool _pool; + private DescriptorSetLayout _uboSetLayout; + private bool _initialized; + private bool _disposed; + + public VulkanMaterialSystem(VulkanContext ctx, VulkanShaderManager shaders) + { + _ctx = ctx; + _shaders = shaders; + } + + /// Uniform ブロック (set=1) 用のディスクリプタセットレイアウトを取得します。 + public DescriptorSetLayout UboSetLayout + { + get + { + EnsureInitialized(); + return _uboSetLayout; + } + } + + /// + /// マテリアルの Uniform 値を UBO へ書き込み、set=1 としてバインドします。 + /// シェーダーに Uniform ブロックが無い場合は何もしません。 + /// + public void Apply(CommandBuffer cmd, Material material, PipelineLayout pipelineLayout) + { + var entry = _shaders.Get(material.Shader.Handle); + if (entry.UniformBlock is not { } block) + return; + + EnsureInitialized(); + + var slotKey = (material, _ctx.FrameIndex); + if (!_slots.TryGetValue(slotKey, out var slot)) + { + slot = CreateSlot(block.Size); + _slots[slotKey] = slot; + } + + // Uniform 値をオフセットに従って書き込む + foreach (var (name, value) in material.Uniforms) + { + if (!block.MemberOffsets.TryGetValue(name, out var offset)) + continue; + WriteValue(slot.Mapped + offset, value, name); + } + + var set = slot.Set; + _ctx.Vk.CmdBindDescriptorSets( + cmd, + PipelineBindPoint.Graphics, + pipelineLayout, + 1, + 1, + in set, + 0, + null + ); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + + var vk = _ctx.Vk; + var device = _ctx.Device; + + foreach (var slot in _slots.Values) + { + vk.DestroyBuffer(device, slot.Buffer, null); + vk.FreeMemory(device, slot.Memory, null); + } + + _slots.Clear(); + + if (_initialized) + { + vk.DestroyDescriptorPool(device, _pool, null); + vk.DestroyDescriptorSetLayout(device, _uboSetLayout, null); + } + } + + private void EnsureInitialized() + { + if (_initialized) + return; + + var vk = _ctx.Vk; + var device = _ctx.Device; + + var poolSize = new DescriptorPoolSize + { + Type = DescriptorType.UniformBuffer, + DescriptorCount = MaxSets, + }; + var poolInfo = new DescriptorPoolCreateInfo + { + SType = StructureType.DescriptorPoolCreateInfo, + MaxSets = MaxSets, + PoolSizeCount = 1, + PPoolSizes = &poolSize, + }; + vk.CreateDescriptorPool(device, in poolInfo, null, out _pool); + + var binding = new DescriptorSetLayoutBinding + { + Binding = 0, + DescriptorType = DescriptorType.UniformBuffer, + DescriptorCount = 1, + StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit, + }; + var layoutInfo = new DescriptorSetLayoutCreateInfo + { + SType = StructureType.DescriptorSetLayoutCreateInfo, + BindingCount = 1, + PBindings = &binding, + }; + vk.CreateDescriptorSetLayout(device, in layoutInfo, null, out _uboSetLayout); + + _initialized = true; + } + + private MaterialSlot CreateSlot(uint size) + { + var vk = _ctx.Vk; + var device = _ctx.Device; + + var (buffer, memory) = _ctx.CreateBuffer( + size, + BufferUsageFlags.UniformBufferBit, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit + ); + + void* mapped; + vk.MapMemory(device, memory, 0, size, 0, &mapped); + + var layout = _uboSetLayout; + var allocInfo = new DescriptorSetAllocateInfo + { + SType = StructureType.DescriptorSetAllocateInfo, + DescriptorPool = _pool, + DescriptorSetCount = 1, + PSetLayouts = &layout, + }; + var result = vk.AllocateDescriptorSets(device, in allocInfo, out var set); + if (result != Result.Success) + throw new InvalidOperationException($"ディスクリプタセットの確保に失敗しました: {result}"); + + var bufferInfo = new DescriptorBufferInfo + { + Buffer = buffer, + Offset = 0, + Range = size, + }; + var write = new WriteDescriptorSet + { + SType = StructureType.WriteDescriptorSet, + DstSet = set, + DstBinding = 0, + DescriptorCount = 1, + DescriptorType = DescriptorType.UniformBuffer, + PBufferInfo = &bufferInfo, + }; + vk.UpdateDescriptorSets(device, 1, in write, 0, null); + + return new MaterialSlot + { + Buffer = buffer, + Memory = memory, + Mapped = (byte*)mapped, + Set = set, + }; + } + + private void WriteValue(byte* dst, object value, string name) + { + switch (value) + { + case float f: + *(float*)dst = f; + break; + case int i: + *(int*)dst = i; + break; + case Vector v: + *(Vector2*)dst = new Vector2(v.X, v.Y); + break; + case VectorInt vi: + *(Vector2*)dst = new Vector2(vi.X, vi.Y); + break; + case Vector2 v2: + *(Vector2*)dst = v2; + break; + case Vector3 v3: + *(Vector3*)dst = v3; + break; + case Vector4 v4: + *(Vector4*)dst = v4; + break; + case Matrix4x4 m: + *(Matrix4x4*)dst = m; + break; + case Texture2D: + if (_warnedUniforms.Add(name)) + LogHelper.Bug( + $"Vulkan バックエンドはまだ Material の Texture2D Uniform ({name}) をサポートしていません。" + ); + break; + } + } + + private sealed class MaterialSlot + { + public required Buffer Buffer { get; init; } + + public required DeviceMemory Memory { get; init; } + + public required byte* Mapped { get; init; } + + public required DescriptorSet Set { get; init; } + } +} diff --git a/Promete/Graphics/Rendering/Vulkan/VulkanPipelineProvider.cs b/Promete/Graphics/Rendering/Vulkan/VulkanPipelineProvider.cs index 391790e..1622115 100644 --- a/Promete/Graphics/Rendering/Vulkan/VulkanPipelineProvider.cs +++ b/Promete/Graphics/Rendering/Vulkan/VulkanPipelineProvider.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Silk.NET.Core.Native; using Silk.NET.Shaderc; using Silk.NET.Vulkan; @@ -13,19 +14,32 @@ internal sealed unsafe class VulkanPipelineProvider : IDisposable { private readonly VulkanContext _ctx; private readonly VulkanResourceManager _resources; + private readonly VulkanShaderManager _shaders; + private readonly VulkanMaterialSystem _materials; private readonly VulkanShaderCompiler _compiler = new(); private readonly Dictionary<(PipelineKind Kind, PassClass Pass, PrimitiveTopology Topology), Pipeline> _cache = []; + private readonly Dictionary<(int ShaderId, CustomKind Kind, PassClass Pass), Pipeline> _customCache = []; private PipelineLayout _textureLayout; private PipelineLayout _primitiveLayout; private PipelineLayout _blitLayout; + private PipelineLayout _pieLayout; + private PipelineLayout _customSpriteLayout; + private PipelineLayout _customBlitLayout; private bool _initialized; private bool _disposed; - public VulkanPipelineProvider(VulkanContext ctx, VulkanResourceManager resources) + public VulkanPipelineProvider( + VulkanContext ctx, + VulkanResourceManager resources, + VulkanShaderManager shaders, + VulkanMaterialSystem materials + ) { _ctx = ctx; _resources = resources; + _shaders = shaders; + _materials = materials; } /// 描画先レンダーパスの系統。 @@ -35,11 +49,30 @@ public enum PassClass Swapchain, } + /// カスタムシェーダーパイプラインの種別。 + public enum CustomKind + { + /// インスタンシングスプライト用 (texture_instanced 互換の頂点レイアウト)。 + Sprite, + + /// フルスクリーンブリット用 (頂点入力なし)。 + Blit, + } + private enum PipelineKind { Texture, Primitive, Blit, + Pie, + } + + private enum VertexLayout + { + None, + Position2D, + PositionUv, + InstancedSprite, } /// インスタンシングテクスチャ描画用のパイプラインレイアウトを取得します。 @@ -72,6 +105,36 @@ public PipelineLayout BlitLayout } } + /// 扇形テクスチャ描画用のパイプラインレイアウトを取得します。 + public PipelineLayout PieLayout + { + get + { + EnsureInitialized(); + return _pieLayout; + } + } + + /// カスタムスプライトシェーダー用のパイプラインレイアウトを取得します。 + public PipelineLayout CustomSpriteLayout + { + get + { + EnsureInitialized(); + return _customSpriteLayout; + } + } + + /// カスタムブリットシェーダー用のパイプラインレイアウトを取得します。 + public PipelineLayout CustomBlitLayout + { + get + { + EnsureInitialized(); + return _customBlitLayout; + } + } + /// インスタンシングテクスチャ描画用のパイプラインを取得します。 public Pipeline GetTexturePipeline(PassClass pass) => GetOrCreate(PipelineKind.Texture, pass, PrimitiveTopology.TriangleList); @@ -84,6 +147,37 @@ public Pipeline GetPrimitivePipeline(PassClass pass, PrimitiveTopology topology) public Pipeline GetBlitPipeline(PassClass pass) => GetOrCreate(PipelineKind.Blit, pass, PrimitiveTopology.TriangleList); + /// 扇形テクスチャ描画用のパイプラインを取得します。 + public Pipeline GetPiePipeline(PassClass pass) => + GetOrCreate(PipelineKind.Pie, pass, PrimitiveTopology.TriangleList); + + /// カスタムシェーダーによるスプライト描画用のパイプラインを取得します。 + public Pipeline GetCustomSpritePipeline(int shaderId, PassClass pass) => + GetOrCreateCustom(shaderId, CustomKind.Sprite, pass); + + /// カスタムシェーダーによるフルスクリーンブリット用のパイプラインを取得します。 + public Pipeline GetCustomBlitPipeline(int shaderId, PassClass pass) => + GetOrCreateCustom(shaderId, CustomKind.Blit, pass); + + /// + /// 破棄されたカスタムシェーダーのパイプラインをキャッシュから除去します。 + /// + public void InvalidateShader(int shaderId) + { + var keys = new List<(int, CustomKind, PassClass)>(); + foreach (var key in _customCache.Keys) + if (key.ShaderId == shaderId) + keys.Add(key); + + var vk = _ctx.Vk; + var device = _ctx.Device; + foreach (var key in keys) + { + if (_customCache.Remove(key, out var pipeline)) + _ctx.DeferDestroy(() => vk.DestroyPipeline(device, pipeline, null)); + } + } + public void Dispose() { if (_disposed) @@ -96,12 +190,18 @@ public void Dispose() foreach (var pipeline in _cache.Values) vk.DestroyPipeline(device, pipeline, null); _cache.Clear(); + foreach (var pipeline in _customCache.Values) + vk.DestroyPipeline(device, pipeline, null); + _customCache.Clear(); if (_initialized) { vk.DestroyPipelineLayout(device, _textureLayout, null); vk.DestroyPipelineLayout(device, _primitiveLayout, null); vk.DestroyPipelineLayout(device, _blitLayout, null); + vk.DestroyPipelineLayout(device, _pieLayout, null); + vk.DestroyPipelineLayout(device, _customSpriteLayout, null); + vk.DestroyPipelineLayout(device, _customBlitLayout, null); } _compiler.Dispose(); @@ -115,6 +215,8 @@ private void EnsureInitialized() var vk = _ctx.Vk; var device = _ctx.Device; var textureSetLayout = _resources.TextureSetLayout; + var uboSetLayout = _materials.UboSetLayout; + var textureAndUboLayouts = stackalloc DescriptorSetLayout[2] { textureSetLayout, uboSetLayout }; // texture: set0 = sampler, push constant = mat4 (vertex) { @@ -153,6 +255,49 @@ private void EnsureInitialized() vk.CreatePipelineLayout(device, in layoutInfo, null, out _blitLayout); } + // pie: set0 = sampler, push constant = mat4 + vec4 + vec2 (両ステージ, 96 bytes) + { + var pushConstant = new PushConstantRange( + ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit, + 0, + 96 + ); + var layoutInfo = new PipelineLayoutCreateInfo + { + SType = StructureType.PipelineLayoutCreateInfo, + SetLayoutCount = 1, + PSetLayouts = &textureSetLayout, + PushConstantRangeCount = 1, + PPushConstantRanges = &pushConstant, + }; + vk.CreatePipelineLayout(device, in layoutInfo, null, out _pieLayout); + } + + // custom sprite: set0 = sampler, set1 = UBO, push constant = mat4 (vertex) + { + var pushConstant = new PushConstantRange(ShaderStageFlags.VertexBit, 0, 64); + var layoutInfo = new PipelineLayoutCreateInfo + { + SType = StructureType.PipelineLayoutCreateInfo, + SetLayoutCount = 2, + PSetLayouts = textureAndUboLayouts, + PushConstantRangeCount = 1, + PPushConstantRanges = &pushConstant, + }; + vk.CreatePipelineLayout(device, in layoutInfo, null, out _customSpriteLayout); + } + + // custom blit: set0 = sampler, set1 = UBO + { + var layoutInfo = new PipelineLayoutCreateInfo + { + SType = StructureType.PipelineLayoutCreateInfo, + SetLayoutCount = 2, + PSetLayouts = textureAndUboLayouts, + }; + vk.CreatePipelineLayout(device, in layoutInfo, null, out _customBlitLayout); + } + _initialized = true; } @@ -165,106 +310,90 @@ private Pipeline GetOrCreate(PipelineKind kind, PassClass pass, PrimitiveTopolog EnsureInitialized(); var pipeline = kind switch { - PipelineKind.Texture => CreateTexturePipeline(pass), - PipelineKind.Primitive => CreatePrimitivePipeline(pass, topology), - PipelineKind.Blit => CreateBlitPipeline(pass), + PipelineKind.Texture => CreateEmbeddedPipeline( + "texture_instanced", + _textureLayout, + pass, + PrimitiveTopology.TriangleList, + enableBlend: true, + VertexLayout.InstancedSprite + ), + PipelineKind.Primitive => CreateEmbeddedPipeline( + "primitive", + _primitiveLayout, + pass, + topology, + enableBlend: true, + VertexLayout.Position2D + ), + PipelineKind.Blit => CreateEmbeddedPipeline( + "blit", + _blitLayout, + pass, + PrimitiveTopology.TriangleList, + enableBlend: false, + VertexLayout.None + ), + PipelineKind.Pie => CreateEmbeddedPipeline( + "pie", + _pieLayout, + pass, + PrimitiveTopology.TriangleList, + enableBlend: true, + VertexLayout.PositionUv + ), _ => throw new ArgumentOutOfRangeException(nameof(kind)), }; _cache[key] = pipeline; return pipeline; } - private RenderPass GetRenderPass(PassClass pass) => - pass == PassClass.Offscreen ? _ctx.OffscreenClearPass : _ctx.SwapchainPass; - - private Pipeline CreateTexturePipeline(PassClass pass) + private Pipeline GetOrCreateCustom(int shaderId, CustomKind kind, PassClass pass) { - // binding 0: 頂点 (pos2 + uv2), binding 1: インスタンス (mat4 + tint + uvRect = 24 floats) - var bindings = stackalloc VertexInputBindingDescription[2] - { - new VertexInputBindingDescription(0, 16, VertexInputRate.Vertex), - new VertexInputBindingDescription(1, 96, VertexInputRate.Instance), - }; - - var attributes = stackalloc VertexInputAttributeDescription[8] - { - new VertexInputAttributeDescription(0, 0, Format.R32G32Sfloat, 0), - new VertexInputAttributeDescription(1, 0, Format.R32G32Sfloat, 8), - new VertexInputAttributeDescription(2, 1, Format.R32G32B32A32Sfloat, 0), - new VertexInputAttributeDescription(3, 1, Format.R32G32B32A32Sfloat, 16), - new VertexInputAttributeDescription(4, 1, Format.R32G32B32A32Sfloat, 32), - new VertexInputAttributeDescription(5, 1, Format.R32G32B32A32Sfloat, 48), - new VertexInputAttributeDescription(6, 1, Format.R32G32B32A32Sfloat, 64), - new VertexInputAttributeDescription(7, 1, Format.R32G32B32A32Sfloat, 80), - }; - - return CreatePipeline( - "texture_instanced", - _textureLayout, - GetRenderPass(pass), - PrimitiveTopology.TriangleList, - enableBlend: true, - bindings, - 2, - attributes, - 8 - ); - } + var key = (shaderId, kind, pass); + if (_customCache.TryGetValue(key, out var cached)) + return cached; - private Pipeline CreatePrimitivePipeline(PassClass pass, PrimitiveTopology topology) - { - var bindings = stackalloc VertexInputBindingDescription[1] - { - new VertexInputBindingDescription(0, 8, VertexInputRate.Vertex), - }; - var attributes = stackalloc VertexInputAttributeDescription[1] + EnsureInitialized(); + var entry = _shaders.Get(shaderId); + var pipeline = kind switch { - new VertexInputAttributeDescription(0, 0, Format.R32G32Sfloat, 0), + CustomKind.Sprite => CreatePipeline( + entry.VertexModule, + entry.FragmentModule, + _customSpriteLayout, + pass, + PrimitiveTopology.TriangleList, + enableBlend: true, + VertexLayout.InstancedSprite + ), + CustomKind.Blit => CreatePipeline( + entry.VertexModule, + entry.FragmentModule, + _customBlitLayout, + pass, + PrimitiveTopology.TriangleList, + enableBlend: false, + VertexLayout.None + ), + _ => throw new ArgumentOutOfRangeException(nameof(kind)), }; - - return CreatePipeline( - "primitive", - _primitiveLayout, - GetRenderPass(pass), - topology, - enableBlend: true, - bindings, - 1, - attributes, - 1 - ); + _customCache[key] = pipeline; + return pipeline; } - private Pipeline CreateBlitPipeline(PassClass pass) - { - return CreatePipeline( - "blit", - _blitLayout, - GetRenderPass(pass), - PrimitiveTopology.TriangleList, - enableBlend: false, - null, - 0, - null, - 0 - ); - } + private RenderPass GetRenderPass(PassClass pass) => + pass == PassClass.Offscreen ? _ctx.OffscreenClearPass : _ctx.SwapchainPass; - private Pipeline CreatePipeline( + private Pipeline CreateEmbeddedPipeline( string shaderName, PipelineLayout layout, - RenderPass renderPass, + PassClass pass, PrimitiveTopology topology, bool enableBlend, - VertexInputBindingDescription* bindings, - uint bindingCount, - VertexInputAttributeDescription* attributes, - uint attributeCount + VertexLayout vertexLayout ) { - var vk = _ctx.Vk; - var device = _ctx.Device; - var vertSpv = _compiler.Compile( EmbeddedResource.GetResourceAsString($"Promete.Resources.shaders.vulkan.{shaderName}.vert"), ShaderKind.VertexShader, @@ -279,7 +408,31 @@ uint attributeCount var vertModule = CreateShaderModule(vertSpv); var fragModule = CreateShaderModule(fragSpv); - var entryPoint = (byte*)Silk.NET.Core.Native.SilkMarshal.StringToPtr("main"); + try + { + return CreatePipeline(vertModule, fragModule, layout, pass, topology, enableBlend, vertexLayout); + } + finally + { + _ctx.Vk.DestroyShaderModule(_ctx.Device, vertModule, null); + _ctx.Vk.DestroyShaderModule(_ctx.Device, fragModule, null); + } + } + + private Pipeline CreatePipeline( + ShaderModule vertModule, + ShaderModule fragModule, + PipelineLayout layout, + PassClass pass, + PrimitiveTopology topology, + bool enableBlend, + VertexLayout vertexLayout + ) + { + var vk = _ctx.Vk; + var device = _ctx.Device; + + var entryPoint = (byte*)SilkMarshal.StringToPtr("main"); var stages = stackalloc PipelineShaderStageCreateInfo[2] { new PipelineShaderStageCreateInfo @@ -298,6 +451,42 @@ uint attributeCount }, }; + // 頂点入力レイアウト + var bindings = stackalloc VertexInputBindingDescription[2]; + var attributes = stackalloc VertexInputAttributeDescription[8]; + uint bindingCount = 0; + uint attributeCount = 0; + switch (vertexLayout) + { + case VertexLayout.Position2D: + bindings[0] = new VertexInputBindingDescription(0, 8, VertexInputRate.Vertex); + attributes[0] = new VertexInputAttributeDescription(0, 0, Format.R32G32Sfloat, 0); + bindingCount = 1; + attributeCount = 1; + break; + case VertexLayout.PositionUv: + bindings[0] = new VertexInputBindingDescription(0, 16, VertexInputRate.Vertex); + attributes[0] = new VertexInputAttributeDescription(0, 0, Format.R32G32Sfloat, 0); + attributes[1] = new VertexInputAttributeDescription(1, 0, Format.R32G32Sfloat, 8); + bindingCount = 1; + attributeCount = 2; + break; + case VertexLayout.InstancedSprite: + bindings[0] = new VertexInputBindingDescription(0, 16, VertexInputRate.Vertex); + bindings[1] = new VertexInputBindingDescription(1, 96, VertexInputRate.Instance); + attributes[0] = new VertexInputAttributeDescription(0, 0, Format.R32G32Sfloat, 0); + attributes[1] = new VertexInputAttributeDescription(1, 0, Format.R32G32Sfloat, 8); + attributes[2] = new VertexInputAttributeDescription(2, 1, Format.R32G32B32A32Sfloat, 0); + attributes[3] = new VertexInputAttributeDescription(3, 1, Format.R32G32B32A32Sfloat, 16); + attributes[4] = new VertexInputAttributeDescription(4, 1, Format.R32G32B32A32Sfloat, 32); + attributes[5] = new VertexInputAttributeDescription(5, 1, Format.R32G32B32A32Sfloat, 48); + attributes[6] = new VertexInputAttributeDescription(6, 1, Format.R32G32B32A32Sfloat, 64); + attributes[7] = new VertexInputAttributeDescription(7, 1, Format.R32G32B32A32Sfloat, 80); + bindingCount = 2; + attributeCount = 8; + break; + } + var vertexInput = new PipelineVertexInputStateCreateInfo { SType = StructureType.PipelineVertexInputStateCreateInfo, @@ -383,7 +572,7 @@ uint attributeCount PColorBlendState = &colorBlend, PDynamicState = &dynamicState, Layout = layout, - RenderPass = renderPass, + RenderPass = GetRenderPass(pass), Subpass = 0, }; @@ -396,9 +585,7 @@ uint attributeCount out var pipeline ); - Silk.NET.Core.Native.SilkMarshal.Free((nint)entryPoint); - vk.DestroyShaderModule(device, vertModule, null); - vk.DestroyShaderModule(device, fragModule, null); + SilkMarshal.Free((nint)entryPoint); if (result != Result.Success) throw new InvalidOperationException($"パイプラインの作成に失敗しました: {result}"); diff --git a/Promete/Graphics/Rendering/Vulkan/VulkanScreenBlitter.cs b/Promete/Graphics/Rendering/Vulkan/VulkanScreenBlitter.cs index 927c820..3143c02 100644 --- a/Promete/Graphics/Rendering/Vulkan/VulkanScreenBlitter.cs +++ b/Promete/Graphics/Rendering/Vulkan/VulkanScreenBlitter.cs @@ -9,7 +9,7 @@ namespace Promete.Graphics.Rendering.Vulkan; /// /// 全描画をスクリーンサイズの にキャプチャし、 -/// スワップチェーンイメージへブリットするクラスです。 +/// ポストプロセスマテリアルを適用した後、スワップチェーンイメージへブリットするクラスです。 /// internal sealed class VulkanScreenBlitter : IScreenBlitter { @@ -17,14 +17,22 @@ internal sealed class VulkanScreenBlitter : IScreenBlitter private readonly VulkanResourceManager _resources; private readonly VulkanPipelineProvider _pipelines; private readonly VulkanRenderTextureProvider _provider; + private readonly VulkanShaderManager _shaders; + private readonly VulkanMaterialSystem _materials; private readonly IGameView _view; - private bool _postProcessWarned; + + // ピンポンバッファ(ポストプロセス使用時に遅延生成) + private RenderTexture? _pingPong0; + private RenderTexture? _pingPong1; + private bool _shaderWarned; public VulkanScreenBlitter( VulkanContext ctx, VulkanResourceManager resources, VulkanPipelineProvider pipelines, VulkanRenderTextureProvider provider, + VulkanShaderManager shaders, + VulkanMaterialSystem materials, IGameView view ) { @@ -32,6 +40,8 @@ IGameView view _resources = resources; _pipelines = pipelines; _provider = provider; + _shaders = shaders; + _materials = materials; _view = view; _view.Resize += OnViewResize; } @@ -41,6 +51,12 @@ IGameView view /// public RenderTexture ScreenRenderTexture { get; private set; } = null!; + /// + /// 最後にスワップチェーンへブリットした RenderTexture (ポストプロセス適用後) を取得します。 + /// スクリーンショットはこれを読み出すことで表示内容と一致させます。 + /// + public RenderTexture? LastBlitSource { get; private set; } + public void InitializeScreenRenderTexture() { ScreenRenderTexture = _provider.Create(_view.Size); @@ -48,41 +64,91 @@ public void InitializeScreenRenderTexture() public unsafe void BlitToScreen(IReadOnlyList materials) { - // TODO: Phase 3+ でポストプロセスマテリアルに対応する - if (materials.Count > 0 && !_postProcessWarned) + var vk = _ctx.Vk; + var src = ScreenRenderTexture; + + // ポストプロセスマテリアルをピンポンバッファへ順に適用 + if (materials.Count > 0) { - _postProcessWarned = true; - LogHelper.Bug("Vulkan バックエンドはまだポストプロセスマテリアルをサポートしていません。"); + EnsurePingPongBuffers(); + Span pingPongs = [_pingPong0!, _pingPong1!]; + var pingIdx = 0; + + foreach (var material in materials) + { + if (!_shaders.Contains(material.Shader.Handle)) + { + if (!_shaderWarned) + { + _shaderWarned = true; + LogHelper.Bug( + "ポストプロセスマテリアルのシェーダーがコンパイルされていないため、スキップします。" + ); + } + + continue; + } + + var dst = pingPongs[pingIdx]; + using (dst.BeginCapture()) + { + var cmd = _ctx.CurrentCommandBuffer; + var pipeline = _pipelines.GetCustomBlitPipeline( + material.Shader.Handle, + VulkanPipelineProvider.PassClass.Offscreen + ); + vk.CmdBindPipeline(cmd, PipelineBindPoint.Graphics, pipeline); + BindSourceTexture(cmd, src, _pipelines.CustomBlitLayout); + _materials.Apply(cmd, material, _pipelines.CustomBlitLayout); + vk.CmdDraw(cmd, 3, 1, 0, 0); + } + + src = dst; + pingIdx = 1 - pingIdx; + } } - var vk = _ctx.Vk; + // 最終結果をスワップチェーンへブリット _ctx.BeginSwapchainPass(Color.Black); + { + var cmd = _ctx.CurrentCommandBuffer; + var pipeline = _pipelines.GetBlitPipeline(VulkanPipelineProvider.PassClass.Swapchain); + vk.CmdBindPipeline(cmd, PipelineBindPoint.Graphics, pipeline); + BindSourceTexture(cmd, src, _pipelines.BlitLayout); + vk.CmdDraw(cmd, 3, 1, 0, 0); + } - var cmd = _ctx.CurrentCommandBuffer; - var pipeline = _pipelines.GetBlitPipeline(VulkanPipelineProvider.PassClass.Swapchain); - vk.CmdBindPipeline(cmd, PipelineBindPoint.Graphics, pipeline); + _ctx.EndSwapchainPass(); + LastBlitSource = src; + } - var descriptorSet = _resources.GetDescriptorSet(ScreenRenderTexture.Texture.Handle); - vk.CmdBindDescriptorSets( + private unsafe void BindSourceTexture(CommandBuffer cmd, RenderTexture src, PipelineLayout layout) + { + var descriptorSet = _resources.GetDescriptorSet(src.Texture.Handle); + _ctx.Vk.CmdBindDescriptorSets( cmd, PipelineBindPoint.Graphics, - _pipelines.BlitLayout, + layout, 0, 1, in descriptorSet, 0, null ); + } - // フルスクリーントライアングル(頂点バッファ不要) - vk.CmdDraw(cmd, 3, 1, 0, 0); - - _ctx.EndSwapchainPass(); + private void EnsurePingPongBuffers() + { + var size = ScreenRenderTexture.Size; + _pingPong0 ??= _provider.Create(size); + _pingPong1 ??= _provider.Create(size); } private void OnViewResize() { var size = _view.Size; ScreenRenderTexture.Resize(size); + _pingPong0?.Resize(size); + _pingPong1?.Resize(size); } } diff --git a/Promete/Graphics/Rendering/Vulkan/VulkanShaderFactory.cs b/Promete/Graphics/Rendering/Vulkan/VulkanShaderFactory.cs index 981b897..63264bc 100644 --- a/Promete/Graphics/Rendering/Vulkan/VulkanShaderFactory.cs +++ b/Promete/Graphics/Rendering/Vulkan/VulkanShaderFactory.cs @@ -4,16 +4,44 @@ namespace Promete.Graphics.Rendering.Vulkan; /// /// Vulkan バックエンドにおける の実装です。 +/// Vulkan 方言の GLSL 450 を shaderc で SPIR-V にコンパイルします。 /// /// -/// TODO: Phase 3+ でカスタムシェーダー (Vulkan 方言 GLSL 450) のコンパイルとマテリアル適用に対応する。 +/// シェーダー規約: +/// +/// スプライト用: texture_instanced.vert 互換の頂点レイアウト (location 0-7) と +/// push_constant の mat4 uProjection を使用すること +/// ポストプロセス用: 頂点入力なし (blit.vert 互換)、set=0, binding=0 に入力テクスチャ +/// カスタム Uniform (Material) は set=1, binding=0 の uniform ブロックに宣言すること +/// /// -internal sealed class VulkanShaderFactory : IShaderFactory +internal sealed class VulkanShaderFactory( + VulkanShaderManager shaders, + VulkanPipelineProvider pipelines +) : IShaderFactory { public void Compile(ShaderProgram program) { - throw new NotSupportedException( - "Vulkan バックエンドはまだカスタムシェーダーをサポートしていません。" - ); + PrometeApp.Current.ThrowIfNotMainThread(); + + var vertexSource = + program.VertexShaderSource + ?? throw new InvalidOperationException( + "頂点シェーダーのソースコードが設定されていません。" + ); + var fragmentSource = + program.FragmentShaderSource + ?? throw new InvalidOperationException( + "フラグメントシェーダーのソースコードが設定されていません。" + ); + + var id = shaders.Compile(vertexSource, fragmentSource, "custom"); + program.SetCompiledData(id, OnDispose); + } + + private void OnDispose(ShaderProgram program) + { + pipelines.InvalidateShader(program.Handle); + shaders.Destroy(program.Handle); } } diff --git a/Promete/Graphics/Rendering/Vulkan/VulkanShaderManager.cs b/Promete/Graphics/Rendering/Vulkan/VulkanShaderManager.cs new file mode 100644 index 0000000..7163904 --- /dev/null +++ b/Promete/Graphics/Rendering/Vulkan/VulkanShaderManager.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Promete.Internal; +using Silk.NET.Shaderc; +using Silk.NET.Vulkan; + +namespace Promete.Graphics.Rendering.Vulkan; + +/// +/// カスタムシェーダー (Vulkan 方言 GLSL 450) のコンパイル結果を int の ID で管理するテーブルです。 +/// はこのテーブルの ID を指します。 +/// +internal sealed unsafe class VulkanShaderManager(VulkanContext ctx) : IDisposable +{ + private readonly VulkanShaderCompiler _compiler = new(); + private readonly Dictionary _shaders = []; + private int _nextId = 1; + private bool _disposed; + + /// + /// GLSL ソースをコンパイルし、シェーダー ID を返します。 + /// + public int Compile(string vertexSource, string fragmentSource, string name) + { + var vertSpv = _compiler.Compile(vertexSource, ShaderKind.VertexShader, $"{name}.vert"); + var fragSpv = _compiler.Compile(fragmentSource, ShaderKind.FragmentShader, $"{name}.frag"); + + // 両ステージの Uniform ブロックを統合 (規約: set=1, binding=0) + var blocks = SpirvReflector + .ReflectUniformBlocks(vertSpv) + .Concat(SpirvReflector.ReflectUniformBlocks(fragSpv)) + .Where(b => b is { Set: 1, Binding: 0 }) + .ToList(); + + SpirvReflector.UniformBlock? merged = null; + if (blocks.Count > 0) + { + var offsets = new Dictionary(); + uint size = 0; + foreach (var block in blocks) + { + foreach (var (memberName, offset) in block.MemberOffsets) + offsets[memberName] = offset; + size = Math.Max(size, block.Size); + } + + merged = new SpirvReflector.UniformBlock + { + Set = 1, + Binding = 0, + MemberOffsets = offsets, + Size = size, + }; + } + + var unsupported = SpirvReflector + .ReflectUniformBlocks(fragSpv) + .FirstOrDefault(b => b is not { Set: 1, Binding: 0 }); + if (unsupported is not null) + { + LogHelper.Bug( + $"カスタムシェーダーの Uniform ブロック (set={unsupported.Set}, binding={unsupported.Binding}) は未対応です。set=1, binding=0 を使用してください。" + ); + } + + var id = _nextId++; + _shaders[id] = new VulkanShaderEntry + { + VertexModule = CreateModule(vertSpv), + FragmentModule = CreateModule(fragSpv), + UniformBlock = merged, + }; + return id; + } + + /// + /// シェーダー情報を取得します。 + /// + public VulkanShaderEntry Get(int id) => _shaders[id]; + + /// + /// シェーダーが登録されているかを取得します。 + /// + public bool Contains(int id) => _shaders.ContainsKey(id); + + /// + /// シェーダーを破棄します。実際の破棄はフレーム完了後に遅延されます。 + /// + public void Destroy(int id) + { + if (!_shaders.Remove(id, out var entry)) + return; + + var vk = ctx.Vk; + var device = ctx.Device; + ctx.DeferDestroy(() => + { + vk.DestroyShaderModule(device, entry.VertexModule, null); + vk.DestroyShaderModule(device, entry.FragmentModule, null); + }); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + + foreach (var entry in _shaders.Values) + { + ctx.Vk.DestroyShaderModule(ctx.Device, entry.VertexModule, null); + ctx.Vk.DestroyShaderModule(ctx.Device, entry.FragmentModule, null); + } + + _shaders.Clear(); + _compiler.Dispose(); + } + + private ShaderModule CreateModule(byte[] spirv) + { + fixed (byte* code = spirv) + { + var createInfo = new ShaderModuleCreateInfo + { + SType = StructureType.ShaderModuleCreateInfo, + CodeSize = (nuint)spirv.Length, + PCode = (uint*)code, + }; + var result = ctx.Vk.CreateShaderModule(ctx.Device, in createInfo, null, out var module); + if (result != Result.Success) + throw new InvalidOperationException($"シェーダーモジュールの作成に失敗しました: {result}"); + return module; + } + } + + /// + /// コンパイル済みシェーダーの情報です。 + /// + public sealed class VulkanShaderEntry + { + /// 頂点シェーダーモジュール。 + public required ShaderModule VertexModule { get; init; } + + /// フラグメントシェーダーモジュール。 + public required ShaderModule FragmentModule { get; init; } + + /// set=1, binding=0 の Uniform ブロック。存在しない場合 null。 + public SpirvReflector.UniformBlock? UniformBlock { get; init; } + } +} diff --git a/Promete/Resources/shaders/vulkan/pie.frag b/Promete/Resources/shaders/vulkan/pie.frag new file mode 100644 index 0000000..fce020e --- /dev/null +++ b/Promete/Resources/shaders/vulkan/pie.frag @@ -0,0 +1,43 @@ +#version 450 +layout(location = 0) in vec2 fUv; + +layout(push_constant) uniform PushConstants +{ + mat4 uMvp; + vec4 uTintColor; + vec2 uAngles; // x = 開始角, y = 終了角 (ラジアン) +}; + +layout(set = 0, binding = 0) uniform sampler2D uTexture0; + +layout(location = 0) out vec4 FragColor; + +const float TWO_PI = 6.28318530718; + +void main() +{ + // UV座標を中心原点の座標系に変換(-0.5 ~ 0.5) + vec2 centered = fUv - vec2(0.5, 0.5); + + // 極座標変換(atan2で角度を計算)し、0 ~ 2π に正規化 + float angle = atan(centered.y, centered.x); + if (angle < 0.0) { + angle += TWO_PI; + } + + float startNorm = uAngles.x; + float endNorm = uAngles.y; + if (startNorm < 0.0) startNorm += TWO_PI; + if (endNorm < 0.0) endNorm += TWO_PI; + + // クリッピング判定(0度をまたぐケースに対応) + bool inRange = endNorm >= startNorm + ? (angle >= startNorm && angle <= endNorm) + : (angle >= startNorm || angle <= endNorm); + + if (!inRange) { + discard; + } + + FragColor = texture(uTexture0, fUv) * uTintColor; +} diff --git a/Promete/Resources/shaders/vulkan/pie.vert b/Promete/Resources/shaders/vulkan/pie.vert new file mode 100644 index 0000000..2358ed0 --- /dev/null +++ b/Promete/Resources/shaders/vulkan/pie.vert @@ -0,0 +1,18 @@ +#version 450 +layout(location = 0) in vec2 vPos; +layout(location = 1) in vec2 vUv; + +layout(location = 0) out vec2 fUv; + +layout(push_constant) uniform PushConstants +{ + mat4 uMvp; + vec4 uTintColor; + vec2 uAngles; // x = 開始角, y = 終了角 (ラジアン) +}; + +void main() +{ + gl_Position = uMvp * vec4(vPos, 0.0, 1.0); + fUv = vUv; +} diff --git a/VULKAN_PORTING_PLAN.md b/VULKAN_PORTING_PLAN.md index 0513fdf..9d1b7df 100644 --- a/VULKAN_PORTING_PLAN.md +++ b/VULKAN_PORTING_PLAN.md @@ -77,8 +77,9 @@ OpenGL 依存コードは以下の 16 ファイル・約 2,600 行に限定さ ### Phase 2: リソース基盤 🚧 大部分実装済み -実装済み: `VulkanResourceManager` (int ID テーブル + staging アップロード + ディスクリプタ管理)、`VulkanTextureFactory`、`VulkanRenderTextureProvider` (パス中断/再開・Resize 対応)、`VulkanPipelineProvider` (パイプラインキャッシュ)、shaderc ランタイムコンパイル (`Silk.NET.Shaderc`)。標準シェーダーは Vulkan GLSL 450 版を実行時コンパイル(事前 SPIR-V 化は将来最適化)。 -未実装: カスタムシェーダー (`VulkanShaderFactory` は NotSupportedException)。 +実装済み: `VulkanResourceManager` (int ID テーブル + staging アップロード + ディスクリプタ管理)、`VulkanTextureFactory`、`VulkanRenderTextureProvider` (パス中断/再開・Resize 対応・V反転UVでGLと見え方統一)、`VulkanPipelineProvider` (パイプラインキャッシュ)、shaderc ランタイムコンパイル (`Silk.NET.Shaderc`)。標準シェーダーは Vulkan GLSL 450 版を実行時コンパイル(事前 SPIR-V 化は将来最適化)。 + +カスタムシェーダーも実装済み: `VulkanShaderFactory` + `VulkanShaderManager` (ID テーブル) + 自前の最小 SPIR-V リフレクタ (`SpirvReflector`、追加ネイティブ依存なし) + `VulkanMaterialSystem` (per-material UBO)。シェーダー規約: カスタム Uniform は set=1, binding=0 の uniform ブロック、テクスチャは set=0, binding=0、射影行列は push_constant。Material の Texture2D Uniform は未対応。 元の計画(規模目安: 1,500 行): @@ -90,8 +91,8 @@ OpenGL 依存コードは以下の 16 ファイル・約 2,600 行に限定さ ### Phase 3: ランナー移植 🚧 主要部分実装済み -実装済み: `VulkanDrawTextureBatchedCommandRunner` (インスタンシング + per-frame アリーナ)、`VulkanDrawPrimitiveCommandRunner`、`VulkanBeginTrim/EndTrimCommandRunner`、`VulkanScreenBlitter` (単純ブリット)。スクリーンショット (`TakeScreenshot`/`SaveScreenshotAsync`) も実装済みで、Promete.Experimental.Vulkan によるピクセル単位の自動検証がパスしている。 -未実装: `DrawPieTextureCommand`、マスク系 (stencil/alpha)、ポストプロセスマテリアル、カスタムマテリアル、線幅 >1 の線 (wideLines)。 +実装済み: `VulkanDrawTextureBatchedCommandRunner` (インスタンシング + per-frame アリーナ + カスタムマテリアル対応)、`VulkanDrawPrimitiveCommandRunner`、`VulkanBeginTrim/EndTrimCommandRunner`、`VulkanDrawPieTextureCommandRunner` (push constant で MVP/tint/角度)、`VulkanScreenBlitter` (ピンポンポストプロセス + 最終ブリット)。スクリーンショット (`TakeScreenshot`/`SaveScreenshotAsync`) はポストプロセス適用後の最終ブリット元を読み出す。Promete.Experimental.Vulkan によるピクセル単位の自動検証 13 項目(スプライト/プリミティブ/ティント/FrameBuffer/PieSprite/カスタムマテリアル/色反転ポストプロセス)がパスしている。 +未実装: マスク系 (stencil/alpha — オフスクリーンパスへのステンシルアタッチメント追加が必要)、PieSprite のカスタムマテリアル、Material の Texture2D Uniform、線幅 >1 の線 (wideLines)。 元の計画(規模目安: 2,000 行): From f948122ad19336013c00678380414f13decda4f9 Mon Sep 17 00:00:00 2001 From: Ebise Lutica <7106976+EbiseLutica@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:13:43 +0900 Subject: [PATCH 10/16] =?UTF-8?q?feat(Vulkan):=20=E3=82=B9=E3=83=86?= =?UTF-8?q?=E3=83=B3=E3=82=B7=E3=83=AB/=E3=82=A2=E3=83=AB=E3=83=95?= =?UTF-8?q?=E3=82=A1=E3=83=9E=E3=82=B9=E3=82=AF=E3=81=AB=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - オフスクリーンパス・RenderTexture にステンシルアタッチメントを追加 (D24S8/D32S8 をフォーマット照会で選択、パス中断/再開をまたいで保持) - パイプラインにステンシルバリアント (None/WriteMask/TestEqual) を追加し、 ランナーは StencilMaskActive に応じて選択 - ステンシルマスク: vkCmdClearAttachments でクリア → マスク形状書き込み (カラー書き込み無効 + Always/Replace) → 子を Equal テストで描画 - アルファマスク: VulkanMaskedContainerHelper が子を RenderTexture へ サブレンダリングし、masked シェーダーで content×mask 合成 (set0=content, set1=mask として既存のテクスチャ毎ディスクリプタを流用) - Experimental: マスク両方式の検証を追加、全17項目パス Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016A9ZxvQBn4vAu8Erah3rHr --- Promete.Experimental.Vulkan/MainScene.cs | 42 +++ .../Backends/Vulkan/VulkanDesktopBackend.cs | 29 +- .../VulkanDrawPieTextureCommandRunner.cs | 7 +- .../VulkanDrawPrimitiveCommandRunner.cs | 11 +- .../VulkanDrawTextureBatchedCommandRunner.cs | 8 +- .../Runners/VulkanMaskCommandRunners.cs | 53 ++++ .../Rendering/Vulkan/VulkanContext.cs | 228 ++++++++++++---- .../Vulkan/VulkanMaskedContainerHelper.cs | 258 ++++++++++++++++++ .../Vulkan/VulkanPipelineProvider.cs | 253 ++++++++++++++--- .../Rendering/Vulkan/VulkanRenderTarget.cs | 9 + .../Vulkan/VulkanRenderTextureProvider.cs | 41 ++- Promete/Resources/shaders/vulkan/masked.frag | 25 ++ Promete/Resources/shaders/vulkan/masked.vert | 17 ++ .../shaders/vulkan/stencil_mask.frag | 25 ++ VULKAN_PORTING_PLAN.md | 3 +- 15 files changed, 906 insertions(+), 103 deletions(-) create mode 100644 Promete/Graphics/Rendering/Vulkan/Runners/VulkanMaskCommandRunners.cs create mode 100644 Promete/Graphics/Rendering/Vulkan/VulkanMaskedContainerHelper.cs create mode 100644 Promete/Resources/shaders/vulkan/masked.frag create mode 100644 Promete/Resources/shaders/vulkan/masked.vert create mode 100644 Promete/Resources/shaders/vulkan/stencil_mask.frag diff --git a/Promete.Experimental.Vulkan/MainScene.cs b/Promete.Experimental.Vulkan/MainScene.cs index 668a6db..2da8c6a 100644 --- a/Promete.Experimental.Vulkan/MainScene.cs +++ b/Promete.Experimental.Vulkan/MainScene.cs @@ -129,6 +129,22 @@ public override void OnStart() customSprite.Material = material; Root.Add(customSprite); + // ステンシルマスク検証: 左半分白・右半分黒のマスク + オレンジ全面スプライト + var halfMask100 = CreateHalfMask((100, 100)); + var orange = App.TextureFactory.CreateSolid(Color.Orange, (100, 100)); + var stencilMasked = new MaskedContainer(halfMask100) { Size = (100, 100) }; + stencilMasked.Location = (450, 300); + stencilMasked.Add(new Sprite(orange)); + Root.Add(stencilMasked); + + // アルファマスク検証: 左半分白・右半分黒のマスク + ピンク全面スプライト + var halfMask60 = CreateHalfMask((60, 60)); + var pink = App.TextureFactory.CreateSolid(Color.HotPink, (60, 60)); + var alphaMasked = new MaskedContainer(halfMask60, useAlphaMask: true) { Size = (60, 60) }; + alphaMasked.Location = (560, 380); + alphaMasked.Add(new Sprite(pink)); + Root.Add(alphaMasked); + // フェーズ2用: 色反転ポストプロセスシェーダー _invertShader = ShaderProgram .Create() @@ -183,6 +199,10 @@ private async Task RunPhase1Async() _failures += Verify(img, 320, 330, Color.Cyan, "PieSprite 右上 1/4 (シアン)"); _failures += Verify(img, 280, 370, Color.DarkSlateBlue, "PieSprite 左下 (背景=切り抜き)"); _failures += Verify(img, 475, 175, Color.FromArgb(255, 102, 0), "カスタムマテリアル (uOverrideColor)"); + _failures += Verify(img, 470, 350, Color.Orange, "ステンシルマスク 左半分 (表示)"); + _failures += Verify(img, 530, 350, Color.DarkSlateBlue, "ステンシルマスク 右半分 (非表示)"); + _failures += Verify(img, 575, 410, Color.HotPink, "アルファマスク 左半分 (表示)"); + _failures += Verify(img, 605, 410, Color.DarkSlateBlue, "アルファマスク 右半分 (非表示)"); // フェーズ2: 色反転ポストプロセスを適用 App.PostProcessMaterials.Add(new Material(_invertShader!)); @@ -222,6 +242,28 @@ private async Task RunPhase2Async() } } + /// + /// 左半分が白、右半分が黒のマスクテクスチャを生成します。 + /// + private Texture2D CreateHalfMask(VectorInt size) + { + var arr = new byte[size.X * size.Y * 4]; + for (var y = 0; y < size.Y; y++) + { + for (var x = 0; x < size.X; x++) + { + var i = ((y * size.X) + x) * 4; + var value = x < size.X / 2 ? (byte)255 : (byte)0; + arr[i + 0] = value; + arr[i + 1] = value; + arr[i + 2] = value; + arr[i + 3] = 255; + } + } + + return App.TextureFactory.Create(arr, size); + } + private static int Verify(Rgba32Image img, int x, int y, Color expected, string label) { var actual = img[x, y]; diff --git a/Promete/Backends/Vulkan/VulkanDesktopBackend.cs b/Promete/Backends/Vulkan/VulkanDesktopBackend.cs index a698897..ed35788 100644 --- a/Promete/Backends/Vulkan/VulkanDesktopBackend.cs +++ b/Promete/Backends/Vulkan/VulkanDesktopBackend.cs @@ -36,6 +36,7 @@ public class VulkanDesktopBackend : BackendBase private VulkanScreenBlitter _screenBlitter = null!; private VulkanDrawTextureBatchedCommandRunner? _textureRunner; private VulkanDrawPieTextureCommandRunner? _pieRunner; + private VulkanMaskedContainerHelper? _maskHelper; public override void OnInitialize(PrometeApp app, WindowOptions opts) { @@ -128,14 +129,25 @@ private void OnLoad() _materialSystem ); _pieRunner = new VulkanDrawPieTextureCommandRunner(_context, _resources, _pipelines); - _app.GetPlugin() - .RegisterRunnerRange( - _textureRunner, - _pieRunner, - new VulkanDrawPrimitiveCommandRunner(_context, _pipelines), - new VulkanBeginTrimCommandRunner(_context), - new VulkanEndTrimCommandRunner(_context) - ); + var queue = _app.GetPlugin(); + _maskHelper = new VulkanMaskedContainerHelper( + _app, + queue, + _context, + _resources, + _pipelines, + _renderTextureProvider + ); + queue.RegisterRunnerRange( + _textureRunner, + _pieRunner, + new VulkanDrawPrimitiveCommandRunner(_context, _pipelines), + new VulkanBeginTrimCommandRunner(_context), + new VulkanEndTrimCommandRunner(_context), + new VulkanBeginStencilMaskCommandRunner(_context, _maskHelper), + new VulkanBeginAlphaMaskCommandRunner(_maskHelper), + new VulkanEndMaskCommandRunner(_context) + ); } private void OnClosing() @@ -145,6 +157,7 @@ private void OnClosing() if (!_context.IsInitialized) return; _context.WaitIdle(); + _maskHelper?.Dispose(); _textureRunner?.Dispose(); _pieRunner?.Dispose(); _pipelines.Dispose(); diff --git a/Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawPieTextureCommandRunner.cs b/Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawPieTextureCommandRunner.cs index 37b10a1..51dc7db 100644 --- a/Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawPieTextureCommandRunner.cs +++ b/Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawPieTextureCommandRunner.cs @@ -109,7 +109,12 @@ private void Draw( var vk = ctx.Vk; var cmd = ctx.CurrentCommandBuffer; - var pipeline = pipelines.GetPiePipeline(VulkanPipelineProvider.PassClass.Offscreen); + var pipeline = pipelines.GetPiePipeline( + VulkanPipelineProvider.PassClass.Offscreen, + ctx.StencilMaskActive + ? VulkanPipelineProvider.StencilMode.TestEqual + : VulkanPipelineProvider.StencilMode.None + ); vk.CmdBindPipeline(cmd, PipelineBindPoint.Graphics, pipeline); vk.CmdPushConstants( cmd, diff --git a/Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawPrimitiveCommandRunner.cs b/Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawPrimitiveCommandRunner.cs index 26b046c..d216c0f 100644 --- a/Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawPrimitiveCommandRunner.cs +++ b/Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawPrimitiveCommandRunner.cs @@ -71,7 +71,8 @@ private void DrawFill(Span vertices, ShapeType type, Color color, int lin var pipeline = pipelines.GetPrimitivePipeline( VulkanPipelineProvider.PassClass.Offscreen, - ToVulkanTopology(type) + ToVulkanTopology(type), + CurrentStencilMode() ); vk.CmdBindPipeline(cmd, PipelineBindPoint.Graphics, pipeline); PushColor(cmd, color); @@ -109,7 +110,8 @@ private void DrawStroke(Span vertices, int lineWidth, Color? lineColor) var pipeline = pipelines.GetPrimitivePipeline( VulkanPipelineProvider.PassClass.Offscreen, - PrimitiveTopology.LineStrip + PrimitiveTopology.LineStrip, + CurrentStencilMode() ); vk.CmdBindPipeline(cmd, PipelineBindPoint.Graphics, pipeline); PushColor(cmd, lc); @@ -131,6 +133,11 @@ private void PushColor(CommandBuffer cmd, Color color) ); } + private VulkanPipelineProvider.StencilMode CurrentStencilMode() => + ctx.StencilMaskActive + ? VulkanPipelineProvider.StencilMode.TestEqual + : VulkanPipelineProvider.StencilMode.None; + /// /// Prometeのを、Vulkanのに変換します。 /// diff --git a/Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawTextureBatchedCommandRunner.cs b/Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawTextureBatchedCommandRunner.cs index fd84db7..780f6be 100644 --- a/Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawTextureBatchedCommandRunner.cs +++ b/Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawTextureBatchedCommandRunner.cs @@ -139,12 +139,16 @@ private void DrawInstanced(List items, Material? material) var vk = _ctx.Vk; var cmdBuffer = _ctx.CurrentCommandBuffer; + var stencil = _ctx.StencilMaskActive + ? VulkanPipelineProvider.StencilMode.TestEqual + : VulkanPipelineProvider.StencilMode.None; var pipeline = useCustom ? _pipelines.GetCustomSpritePipeline( material!.Shader.Handle, - VulkanPipelineProvider.PassClass.Offscreen + VulkanPipelineProvider.PassClass.Offscreen, + stencil ) - : _pipelines.GetTexturePipeline(VulkanPipelineProvider.PassClass.Offscreen); + : _pipelines.GetTexturePipeline(VulkanPipelineProvider.PassClass.Offscreen, stencil); var layout = useCustom ? _pipelines.CustomSpriteLayout : _pipelines.TextureLayout; vk.CmdBindPipeline(cmdBuffer, PipelineBindPoint.Graphics, pipeline); diff --git a/Promete/Graphics/Rendering/Vulkan/Runners/VulkanMaskCommandRunners.cs b/Promete/Graphics/Rendering/Vulkan/Runners/VulkanMaskCommandRunners.cs new file mode 100644 index 0000000..10dae52 --- /dev/null +++ b/Promete/Graphics/Rendering/Vulkan/Runners/VulkanMaskCommandRunners.cs @@ -0,0 +1,53 @@ +using Promete.Graphics.Rendering.Commands; + +namespace Promete.Graphics.Rendering.Vulkan.Runners; + +/// +/// でステンシルマスクの書き込みフェーズを開始するランナーです。 +/// +internal sealed class VulkanBeginStencilMaskCommandRunner( + VulkanContext ctx, + VulkanMaskedContainerHelper maskHelper +) : CommandRunner +{ + public override void Execute(BeginStencilMaskCommand command) + { + if (!ctx.IsFrameActive) + return; + + // ステンシルをクリアし、マスク形状を書き込む + ctx.ClearStencil(); + maskHelper.DrawMaskToStencil(command.MaskTexture, command.Container); + + // 以降の描画はステンシルテスト (Equal, ref=1) 付きパイプラインを使用する + ctx.StencilMaskActive = true; + } +} + +/// +/// でアルファマスク合成を行うランナーです。 +/// +internal sealed class VulkanBeginAlphaMaskCommandRunner(VulkanMaskedContainerHelper maskHelper) + : CommandRunner +{ + public override void Execute(BeginAlphaMaskCommand command) + { + var contentTexture = maskHelper.RenderToTexture(command.Container, command.Context); + maskHelper.DrawMasked(contentTexture, command.MaskTexture, command.Container); + } +} + +/// +/// でステンシルマスクを終了するランナーです。 +/// +internal sealed class VulkanEndMaskCommandRunner(VulkanContext ctx) : CommandRunner +{ + public override void Execute(EndMaskCommand command) + { + if (!ctx.IsFrameActive) + return; + + ctx.StencilMaskActive = false; + ctx.ClearStencil(); + } +} diff --git a/Promete/Graphics/Rendering/Vulkan/VulkanContext.cs b/Promete/Graphics/Rendering/Vulkan/VulkanContext.cs index 1e3af78..7762f9d 100644 --- a/Promete/Graphics/Rendering/Vulkan/VulkanContext.cs +++ b/Promete/Graphics/Rendering/Vulkan/VulkanContext.cs @@ -119,6 +119,15 @@ public VulkanContext(IWindow window) /// 現在のトリム (シザー) 領域。null なら全域。 public Rect2D? TrimScissor { get; private set; } + /// オフスクリーンターゲットのステンシルフォーマットを取得します。 + public Format StencilFormat { get; private set; } + + /// + /// ステンシルマスクが有効かどうかを取得または設定します。 + /// 有効な間、ランナーはステンシルテスト (Equal, ref=1) 付きパイプラインを使用します。 + /// + public bool StencilMaskActive { get; set; } + /// 現在の描画ターゲットの大きさを取得します。 public Extent2D CurrentTargetExtent => _targetStack.Count > 0 ? _targetStack.Peek().Extent : _swapchainExtent; @@ -134,6 +143,7 @@ public void Initialize(string appName) CreateLogicalDevice(); CreateSwapchain(); CreateImageViews(); + ChooseStencilFormat(); CreateRenderPasses(); CreateFramebuffers(); CreateCommandPools(); @@ -429,7 +439,11 @@ ImageUsageFlags usage /// /// 2D イメージビューを作成します。 /// - public ImageView CreateImageView2D(Image image, Format format) + public ImageView CreateImageView2D( + Image image, + Format format, + ImageAspectFlags aspect = ImageAspectFlags.ColorBit + ) { var createInfo = new ImageViewCreateInfo { @@ -437,7 +451,7 @@ public ImageView CreateImageView2D(Image image, Format format) Image = image, ViewType = ImageViewType.Type2D, Format = format, - SubresourceRange = new ImageSubresourceRange(ImageAspectFlags.ColorBit, 0, 1, 0, 1), + SubresourceRange = new ImageSubresourceRange(aspect, 0, 1, 0, 1), }; ThrowIfFailed( Vk.CreateImageView(_device, in createInfo, null, out var view), @@ -447,16 +461,22 @@ public ImageView CreateImageView2D(Image image, Format format) } /// - /// オフスクリーンパス用のフレームバッファを作成します。 + /// オフスクリーンパス用のフレームバッファを作成します。(カラー + ステンシル) /// - public Framebuffer CreateOffscreenFramebuffer(ImageView view, uint width, uint height) + public Framebuffer CreateOffscreenFramebuffer( + ImageView colorView, + ImageView stencilView, + uint width, + uint height + ) { + var attachments = stackalloc ImageView[2] { colorView, stencilView }; var createInfo = new FramebufferCreateInfo { SType = StructureType.FramebufferCreateInfo, RenderPass = _offscreenClearPass, - AttachmentCount = 1, - PAttachments = &view, + AttachmentCount = 2, + PAttachments = attachments, Width = width, Height = height, Layers = 1, @@ -468,6 +488,27 @@ public Framebuffer CreateOffscreenFramebuffer(ImageView view, uint width, uint h return framebuffer; } + /// + /// 現在の描画ターゲットのステンシルアタッチメントを 0 でクリアします。 + /// レンダーパス記録中に呼び出してください。 + /// + public void ClearStencil() + { + EnsureFrameActive(); + var attachment = new ClearAttachment + { + AspectMask = ImageAspectFlags.StencilBit, + ClearValue = new ClearValue { DepthStencil = new ClearDepthStencilValue(1f, 0) }, + }; + var rect = new ClearRect + { + Rect = new Rect2D(new Offset2D(0, 0), CurrentTargetExtent), + BaseArrayLayer = 0, + LayerCount = 1, + }; + Vk.CmdClearAttachments(CurrentCommandBuffer, 1, in attachment, 1, in rect); + } + /// /// 一時的なコマンドバッファでコマンドを実行し、完了まで待機します。(リソース転送用) /// @@ -514,7 +555,8 @@ public void TransitionImageLayout( PipelineStageFlags srcStage, AccessFlags srcAccess, PipelineStageFlags dstStage, - AccessFlags dstAccess + AccessFlags dstAccess, + ImageAspectFlags aspect = ImageAspectFlags.ColorBit ) { var barrier = new ImageMemoryBarrier @@ -525,7 +567,7 @@ AccessFlags dstAccess SrcQueueFamilyIndex = Vk.QueueFamilyIgnored, DstQueueFamilyIndex = Vk.QueueFamilyIgnored, Image = image, - SubresourceRange = new ImageSubresourceRange(ImageAspectFlags.ColorBit, 0, 1, 0, 1), + SubresourceRange = new ImageSubresourceRange(aspect, 0, 1, 0, 1), SrcAccessMask = srcAccess, DstAccessMask = dstAccess, }; @@ -692,15 +734,19 @@ private void EnsureFrameActive() private void BeginOffscreenPass(VulkanRenderTarget target, Color? clearColor) { var cmd = CurrentCommandBuffer; - var clearValue = new ClearValue(ToClearColor(clearColor ?? Color.Transparent)); + var clearValues = stackalloc ClearValue[2] + { + new ClearValue(ToClearColor(clearColor ?? Color.Transparent)), + new ClearValue { DepthStencil = new ClearDepthStencilValue(1f, 0) }, + }; var beginInfo = new RenderPassBeginInfo { SType = StructureType.RenderPassBeginInfo, RenderPass = clearColor.HasValue ? _offscreenClearPass : _offscreenLoadPass, Framebuffer = target.Framebuffer, RenderArea = new Rect2D(new Offset2D(0, 0), target.Extent), - ClearValueCount = 1, - PClearValues = &clearValue, + ClearValueCount = 2, + PClearValues = clearValues, }; Vk.CmdBeginRenderPass(cmd, in beginInfo, SubpassContents.Inline); @@ -1076,78 +1122,154 @@ private void CreateImageViews() _swapchainImageViews[i] = CreateImageView2D(_swapchainImages[i], _swapchainFormat); } + private void ChooseStencilFormat() + { + // 環境によってサポートが異なるため、利用可能なステンシル付きフォーマットを選択する + Span candidates = [Format.D24UnormS8Uint, Format.D32SfloatS8Uint]; + foreach (var format in candidates) + { + Vk.GetPhysicalDeviceFormatProperties(_physicalDevice, format, out var props); + if ((props.OptimalTilingFeatures & FormatFeatureFlags.DepthStencilAttachmentBit) != 0) + { + StencilFormat = format; + return; + } + } + + throw new NotSupportedException("ステンシルアタッチメントに使用できるフォーマットがありません。"); + } + private void CreateRenderPasses() { - _swapchainPass = CreateRenderPass( - _swapchainFormat, - AttachmentLoadOp.Clear, - ImageLayout.Undefined, - ImageLayout.PresentSrcKhr, - forSampling: false - ); - _offscreenClearPass = CreateRenderPass( - OffscreenFormat, - AttachmentLoadOp.Clear, - ImageLayout.General, - ImageLayout.General, - forSampling: true - ); - _offscreenLoadPass = CreateRenderPass( - OffscreenFormat, - AttachmentLoadOp.Load, - ImageLayout.General, - ImageLayout.General, - forSampling: true - ); + _swapchainPass = CreateSwapchainRenderPass(); + _offscreenClearPass = CreateOffscreenRenderPass(clear: true); + _offscreenLoadPass = CreateOffscreenRenderPass(clear: false); } - private RenderPass CreateRenderPass( - Format format, - AttachmentLoadOp loadOp, - ImageLayout initialLayout, - ImageLayout finalLayout, - bool forSampling - ) + private RenderPass CreateSwapchainRenderPass() { var colorAttachment = new AttachmentDescription { - Format = format, + Format = _swapchainFormat, + Samples = SampleCountFlags.Count1Bit, + LoadOp = AttachmentLoadOp.Clear, + StoreOp = AttachmentStoreOp.Store, + StencilLoadOp = AttachmentLoadOp.DontCare, + StencilStoreOp = AttachmentStoreOp.DontCare, + InitialLayout = ImageLayout.Undefined, + FinalLayout = ImageLayout.PresentSrcKhr, + }; + + var colorRef = new AttachmentReference(0, ImageLayout.ColorAttachmentOptimal); + + var subpass = new SubpassDescription + { + PipelineBindPoint = PipelineBindPoint.Graphics, + ColorAttachmentCount = 1, + PColorAttachments = &colorRef, + }; + + var dependency = new SubpassDependency + { + SrcSubpass = Vk.SubpassExternal, + DstSubpass = 0, + SrcStageMask = PipelineStageFlags.ColorAttachmentOutputBit, + SrcAccessMask = 0, + DstStageMask = PipelineStageFlags.ColorAttachmentOutputBit, + DstAccessMask = AccessFlags.ColorAttachmentWriteBit, + }; + + var createInfo = new RenderPassCreateInfo + { + SType = StructureType.RenderPassCreateInfo, + AttachmentCount = 1, + PAttachments = &colorAttachment, + SubpassCount = 1, + PSubpasses = &subpass, + DependencyCount = 1, + PDependencies = &dependency, + }; + + ThrowIfFailed( + Vk.CreateRenderPass(_device, in createInfo, null, out var renderPass), + "レンダーパスの作成" + ); + return renderPass; + } + + private RenderPass CreateOffscreenRenderPass(bool clear) + { + var attachments = stackalloc AttachmentDescription[2]; + + // カラーアタッチメント (サンプリングを単純化するため General レイアウトを維持) + attachments[0] = new AttachmentDescription + { + Format = OffscreenFormat, Samples = SampleCountFlags.Count1Bit, - LoadOp = loadOp, + LoadOp = clear ? AttachmentLoadOp.Clear : AttachmentLoadOp.Load, StoreOp = AttachmentStoreOp.Store, StencilLoadOp = AttachmentLoadOp.DontCare, StencilStoreOp = AttachmentStoreOp.DontCare, - InitialLayout = initialLayout, - FinalLayout = finalLayout, + InitialLayout = ImageLayout.General, + FinalLayout = ImageLayout.General, + }; + + // ステンシルアタッチメント (パス中断/再開をまたいで保持するため Load/Store) + attachments[1] = new AttachmentDescription + { + Format = StencilFormat, + Samples = SampleCountFlags.Count1Bit, + LoadOp = AttachmentLoadOp.DontCare, + StoreOp = AttachmentStoreOp.DontCare, + StencilLoadOp = clear ? AttachmentLoadOp.Clear : AttachmentLoadOp.Load, + StencilStoreOp = AttachmentStoreOp.Store, + InitialLayout = ImageLayout.DepthStencilAttachmentOptimal, + FinalLayout = ImageLayout.DepthStencilAttachmentOptimal, }; var colorRef = new AttachmentReference(0, ImageLayout.ColorAttachmentOptimal); + var stencilRef = new AttachmentReference(1, ImageLayout.DepthStencilAttachmentOptimal); var subpass = new SubpassDescription { PipelineBindPoint = PipelineBindPoint.Graphics, ColorAttachmentCount = 1, PColorAttachments = &colorRef, + PDepthStencilAttachment = &stencilRef, }; + const PipelineStageFlags stencilStages = + PipelineStageFlags.EarlyFragmentTestsBit | PipelineStageFlags.LateFragmentTestsBit; + // 開始依存: 前段の描画/読み取り完了を待つ - // 終了依存 (forSampling): パス完了後のフラグメントシェーダーからの読み取りを同期する + // 終了依存: パス完了後のフラグメントシェーダー/転送からの読み取りを同期する var dependencies = stackalloc SubpassDependency[2]; dependencies[0] = new SubpassDependency { SrcSubpass = Vk.SubpassExternal, DstSubpass = 0, - SrcStageMask = PipelineStageFlags.ColorAttachmentOutputBit | PipelineStageFlags.FragmentShaderBit, - SrcAccessMask = AccessFlags.ColorAttachmentWriteBit | AccessFlags.ShaderReadBit, - DstStageMask = PipelineStageFlags.ColorAttachmentOutputBit, - DstAccessMask = AccessFlags.ColorAttachmentWriteBit | AccessFlags.ColorAttachmentReadBit, + SrcStageMask = + PipelineStageFlags.ColorAttachmentOutputBit + | PipelineStageFlags.FragmentShaderBit + | stencilStages, + SrcAccessMask = + AccessFlags.ColorAttachmentWriteBit + | AccessFlags.ShaderReadBit + | AccessFlags.DepthStencilAttachmentWriteBit, + DstStageMask = PipelineStageFlags.ColorAttachmentOutputBit | stencilStages, + DstAccessMask = + AccessFlags.ColorAttachmentWriteBit + | AccessFlags.ColorAttachmentReadBit + | AccessFlags.DepthStencilAttachmentWriteBit + | AccessFlags.DepthStencilAttachmentReadBit, }; dependencies[1] = new SubpassDependency { SrcSubpass = 0, DstSubpass = Vk.SubpassExternal, - SrcStageMask = PipelineStageFlags.ColorAttachmentOutputBit, - SrcAccessMask = AccessFlags.ColorAttachmentWriteBit, + SrcStageMask = PipelineStageFlags.ColorAttachmentOutputBit | stencilStages, + SrcAccessMask = + AccessFlags.ColorAttachmentWriteBit | AccessFlags.DepthStencilAttachmentWriteBit, DstStageMask = PipelineStageFlags.FragmentShaderBit | PipelineStageFlags.TransferBit, DstAccessMask = AccessFlags.ShaderReadBit | AccessFlags.TransferReadBit, }; @@ -1155,11 +1277,11 @@ bool forSampling var createInfo = new RenderPassCreateInfo { SType = StructureType.RenderPassCreateInfo, - AttachmentCount = 1, - PAttachments = &colorAttachment, + AttachmentCount = 2, + PAttachments = attachments, SubpassCount = 1, PSubpasses = &subpass, - DependencyCount = forSampling ? 2u : 1u, + DependencyCount = 2, PDependencies = dependencies, }; diff --git a/Promete/Graphics/Rendering/Vulkan/VulkanMaskedContainerHelper.cs b/Promete/Graphics/Rendering/Vulkan/VulkanMaskedContainerHelper.cs new file mode 100644 index 0000000..593bcb9 --- /dev/null +++ b/Promete/Graphics/Rendering/Vulkan/VulkanMaskedContainerHelper.cs @@ -0,0 +1,258 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Numerics; +using Promete.Nodes; +using Silk.NET.Vulkan; +using Buffer = Silk.NET.Vulkan.Buffer; + +namespace Promete.Graphics.Rendering.Vulkan; + +/// +/// のレンダリングを支援するヘルパークラスです。 +/// アルファマスク方式のサブレンダリングと、ステンシル/アルファマスクの描画を担います。 +/// +internal sealed unsafe class VulkanMaskedContainerHelper( + PrometeApp app, + RenderCommandQueue queue, + VulkanContext ctx, + VulkanResourceManager resources, + VulkanPipelineProvider pipelines, + IRenderTextureProvider renderTextureProvider +) : IDisposable +{ + // push constant: mat4 (16) + vec4 tint (4) = 20 floats + private const int PushConstantFloats = 20; + + // MaskedContainer ごとの RenderTexture キャッシュ + private readonly Dictionary _renderTextureCache = []; + + private Buffer _quadVbo; + private DeviceMemory _quadVboMemory; + private Buffer _quadEbo; + private DeviceMemory _quadEboMemory; + private bool _initialized; + private bool _disposed; + + /// + /// MaskedContainerの子要素を専用の RenderTexture にレンダリングし、テクスチャを返します。 + /// + public Texture2D RenderToTexture(MaskedContainer container, RenderContext renderContext) + { + var size = container.Size; + if (size.X <= 0 || size.Y <= 0) + size = new VectorInt(1, 1); + + // RenderTexture を取得または作成 + if (!_renderTextureCache.TryGetValue(container, out var rt)) + { + rt = renderTextureProvider.Create(size); + _renderTextureCache[container] = rt; + } + else if (rt.Size != size) + { + rt.Resize(size); + } + + using var capture = rt.BeginCapture(Color.Transparent); + + // 子要素を相対座標でレンダリングするため、一時的にMaskedContainerの変換を除去 + // (GL と異なり Vulkan の RT は top-down のため Y 反転は不要) + var originalLocation = container.Location; + var originalAngle = container.Angle; + var originalScale = container.Scale; + var originalParent = container.Parent; + + container.Parent = null; + container.Location = (0, 0); + container.Angle = 0.Degrees; + container.Scale = (1, 1); + + // 子要素のModelMatrixを再計算させる + container.BeforeRender(); + var sorted = container.SortedChildren; + foreach (var child in sorted) + child.BeforeRender(); + + // 子要素をコマンドキュー経由でレンダリング(スコープで外側を保護) + queue.PushScope(); + foreach (var child in sorted) + app.CollectNode(child, queue, renderContext); + queue.PopScopeAndFlush(); + + // MaskedContainerの状態を元に戻す + container.Parent = originalParent; + container.Location = originalLocation; + container.Angle = originalAngle; + container.Scale = originalScale; + + container.BeforeRender(); + foreach (var child in sorted) + child.BeforeRender(); + + return rt.Texture; + } + + /// + /// ステンシルバッファにマスクテクスチャの形状を書き込みます。 + /// + public void DrawMaskToStencil(Texture2D maskTexture, Node node) + { + PrometeApp.Current.ThrowIfNotMainThread(); + if (!ctx.IsFrameActive || !resources.Contains(maskTexture.Handle)) + return; + + EnsureInitialized(); + + var pipeline = pipelines.GetStencilWritePipeline(VulkanPipelineProvider.PassClass.Offscreen); + var maskSet = resources.GetDescriptorSet(maskTexture.Handle); + DrawQuad(pipeline, pipelines.StencilWriteLayout, node, [maskSet]); + } + + /// + /// コンテンツテクスチャにマスクテクスチャの濃淡を適用して描画します。 + /// + public void DrawMasked(Texture2D contentTexture, Texture2D maskTexture, Node node) + { + PrometeApp.Current.ThrowIfNotMainThread(); + if ( + !ctx.IsFrameActive + || !resources.Contains(contentTexture.Handle) + || !resources.Contains(maskTexture.Handle) + ) + return; + + EnsureInitialized(); + + var pipeline = pipelines.GetMaskedPipeline(VulkanPipelineProvider.PassClass.Offscreen); + var contentSet = resources.GetDescriptorSet(contentTexture.Handle); + var maskSet = resources.GetDescriptorSet(maskTexture.Handle); + DrawQuad(pipeline, pipelines.MaskedLayout, node, [contentSet, maskSet]); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + + foreach (var rt in _renderTextureCache.Values) + rt.Dispose(); + _renderTextureCache.Clear(); + + if (!_initialized) + return; + var vk = ctx.Vk; + vk.DestroyBuffer(ctx.Device, _quadVbo, null); + vk.FreeMemory(ctx.Device, _quadVboMemory, null); + vk.DestroyBuffer(ctx.Device, _quadEbo, null); + vk.FreeMemory(ctx.Device, _quadEboMemory, null); + } + + /// + /// ノードの位置・サイズでクワッドを描画します。 + /// + private void DrawQuad( + Pipeline pipeline, + PipelineLayout layout, + Node node, + ReadOnlySpan descriptorSets + ) + { + var vk = ctx.Vk; + var cmd = ctx.CurrentCommandBuffer; + + var size = node.Size; + var model = Matrix4x4.CreateScale(size.X, size.Y, 1) * node.ModelMatrix; + + var extent = ctx.CurrentTargetExtent; + var projection = Matrix4x4.CreateOrthographicOffCenter( + 0, + extent.Width, + 0, + extent.Height, + -1f, + 1f + ); + var mvp = model * projection; + + var push = stackalloc float[PushConstantFloats]; + *(Matrix4x4*)push = mvp; + push[16] = 1f; + push[17] = 1f; + push[18] = 1f; + push[19] = 1f; + + vk.CmdBindPipeline(cmd, PipelineBindPoint.Graphics, pipeline); + vk.CmdPushConstants( + cmd, + layout, + ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit, + 0, + PushConstantFloats * sizeof(float), + push + ); + + fixed (DescriptorSet* sets = descriptorSets) + { + vk.CmdBindDescriptorSets( + cmd, + PipelineBindPoint.Graphics, + layout, + 0, + (uint)descriptorSets.Length, + sets, + 0, + null + ); + } + + var offset = 0ul; + vk.CmdBindVertexBuffers(cmd, 0, 1, in _quadVbo, in offset); + vk.CmdBindIndexBuffer(cmd, _quadEbo, 0, IndexType.Uint32); + vk.CmdDrawIndexed(cmd, 6, 1, 0, 0, 0); + } + + private void EnsureInitialized() + { + if (_initialized) + return; + + Span vertices = + [ + 1.0f, 0.0f, 1.0f, 0.0f, + 1.0f, 1.0f, 1.0f, 1.0f, + 0.0f, 1.0f, 0.0f, 1.0f, + 0.0f, 0.0f, 0.0f, 0.0f, + ]; + Span indices = [0, 1, 3, 1, 2, 3]; + + (_quadVbo, _quadVboMemory) = CreateStaticBuffer(vertices, BufferUsageFlags.VertexBufferBit); + (_quadEbo, _quadEboMemory) = CreateStaticBuffer(indices, BufferUsageFlags.IndexBufferBit); + _initialized = true; + } + + private (Buffer Buffer, DeviceMemory Memory) CreateStaticBuffer( + ReadOnlySpan data, + BufferUsageFlags usage + ) + where T : unmanaged + { + var size = (ulong)(data.Length * sizeof(T)); + var (buffer, memory) = ctx.CreateBuffer( + size, + usage, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit + ); + + void* mapped; + ctx.Vk.MapMemory(ctx.Device, memory, 0, size, 0, &mapped); + fixed (T* src = data) + { + System.Buffer.MemoryCopy(src, mapped, size, size); + } + + ctx.Vk.UnmapMemory(ctx.Device, memory); + return (buffer, memory); + } +} diff --git a/Promete/Graphics/Rendering/Vulkan/VulkanPipelineProvider.cs b/Promete/Graphics/Rendering/Vulkan/VulkanPipelineProvider.cs index 1622115..ff0a30e 100644 --- a/Promete/Graphics/Rendering/Vulkan/VulkanPipelineProvider.cs +++ b/Promete/Graphics/Rendering/Vulkan/VulkanPipelineProvider.cs @@ -17,8 +17,14 @@ internal sealed unsafe class VulkanPipelineProvider : IDisposable private readonly VulkanShaderManager _shaders; private readonly VulkanMaterialSystem _materials; private readonly VulkanShaderCompiler _compiler = new(); - private readonly Dictionary<(PipelineKind Kind, PassClass Pass, PrimitiveTopology Topology), Pipeline> _cache = []; - private readonly Dictionary<(int ShaderId, CustomKind Kind, PassClass Pass), Pipeline> _customCache = []; + private readonly Dictionary< + (PipelineKind Kind, PassClass Pass, PrimitiveTopology Topology, StencilMode Stencil), + Pipeline + > _cache = []; + private readonly Dictionary< + (int ShaderId, CustomKind Kind, PassClass Pass, StencilMode Stencil), + Pipeline + > _customCache = []; private PipelineLayout _textureLayout; private PipelineLayout _primitiveLayout; @@ -26,6 +32,8 @@ internal sealed unsafe class VulkanPipelineProvider : IDisposable private PipelineLayout _pieLayout; private PipelineLayout _customSpriteLayout; private PipelineLayout _customBlitLayout; + private PipelineLayout _maskedLayout; + private PipelineLayout _stencilWriteLayout; private bool _initialized; private bool _disposed; @@ -59,12 +67,27 @@ public enum CustomKind Blit, } + /// ステンシルの動作モード。 + public enum StencilMode + { + /// ステンシルテスト無効。 + None, + + /// マスク書き込み (Always/Replace, ref=1, カラー書き込みなし)。 + WriteMask, + + /// マスク適用 (Equal, ref=1, 書き込みなし)。 + TestEqual, + } + private enum PipelineKind { Texture, Primitive, Blit, Pie, + Masked, + StencilWrite, } private enum VertexLayout @@ -135,36 +158,75 @@ public PipelineLayout CustomBlitLayout } } + /// アルファマスク合成用のパイプラインレイアウトを取得します。 + public PipelineLayout MaskedLayout + { + get + { + EnsureInitialized(); + return _maskedLayout; + } + } + + /// ステンシル書き込み用のパイプラインレイアウトを取得します。 + public PipelineLayout StencilWriteLayout + { + get + { + EnsureInitialized(); + return _stencilWriteLayout; + } + } + /// インスタンシングテクスチャ描画用のパイプラインを取得します。 - public Pipeline GetTexturePipeline(PassClass pass) => - GetOrCreate(PipelineKind.Texture, pass, PrimitiveTopology.TriangleList); + public Pipeline GetTexturePipeline(PassClass pass, StencilMode stencil = StencilMode.None) => + GetOrCreate(PipelineKind.Texture, pass, PrimitiveTopology.TriangleList, stencil); /// プリミティブ描画用のパイプラインを取得します。 - public Pipeline GetPrimitivePipeline(PassClass pass, PrimitiveTopology topology) => - GetOrCreate(PipelineKind.Primitive, pass, topology); + public Pipeline GetPrimitivePipeline( + PassClass pass, + PrimitiveTopology topology, + StencilMode stencil = StencilMode.None + ) => GetOrCreate(PipelineKind.Primitive, pass, topology, stencil); /// フルスクリーンブリット用のパイプラインを取得します。 public Pipeline GetBlitPipeline(PassClass pass) => - GetOrCreate(PipelineKind.Blit, pass, PrimitiveTopology.TriangleList); + GetOrCreate(PipelineKind.Blit, pass, PrimitiveTopology.TriangleList, StencilMode.None); /// 扇形テクスチャ描画用のパイプラインを取得します。 - public Pipeline GetPiePipeline(PassClass pass) => - GetOrCreate(PipelineKind.Pie, pass, PrimitiveTopology.TriangleList); + public Pipeline GetPiePipeline(PassClass pass, StencilMode stencil = StencilMode.None) => + GetOrCreate(PipelineKind.Pie, pass, PrimitiveTopology.TriangleList, stencil); + + /// アルファマスク合成用のパイプラインを取得します。 + public Pipeline GetMaskedPipeline(PassClass pass) => + GetOrCreate(PipelineKind.Masked, pass, PrimitiveTopology.TriangleList, StencilMode.None); + + /// ステンシルへのマスク書き込み用のパイプラインを取得します。 + public Pipeline GetStencilWritePipeline(PassClass pass) => + GetOrCreate( + PipelineKind.StencilWrite, + pass, + PrimitiveTopology.TriangleList, + StencilMode.WriteMask + ); /// カスタムシェーダーによるスプライト描画用のパイプラインを取得します。 - public Pipeline GetCustomSpritePipeline(int shaderId, PassClass pass) => - GetOrCreateCustom(shaderId, CustomKind.Sprite, pass); + public Pipeline GetCustomSpritePipeline( + int shaderId, + PassClass pass, + StencilMode stencil = StencilMode.None + ) => GetOrCreateCustom(shaderId, CustomKind.Sprite, pass, stencil); /// カスタムシェーダーによるフルスクリーンブリット用のパイプラインを取得します。 public Pipeline GetCustomBlitPipeline(int shaderId, PassClass pass) => - GetOrCreateCustom(shaderId, CustomKind.Blit, pass); + GetOrCreateCustom(shaderId, CustomKind.Blit, pass, StencilMode.None); /// /// 破棄されたカスタムシェーダーのパイプラインをキャッシュから除去します。 /// public void InvalidateShader(int shaderId) { - var keys = new List<(int, CustomKind, PassClass)>(); + var keys = new List<(int, CustomKind, PassClass, StencilMode)>(); foreach (var key in _customCache.Keys) if (key.ShaderId == shaderId) keys.Add(key); @@ -202,6 +264,8 @@ public void Dispose() vk.DestroyPipelineLayout(device, _pieLayout, null); vk.DestroyPipelineLayout(device, _customSpriteLayout, null); vk.DestroyPipelineLayout(device, _customBlitLayout, null); + vk.DestroyPipelineLayout(device, _maskedLayout, null); + vk.DestroyPipelineLayout(device, _stencilWriteLayout, null); } _compiler.Dispose(); @@ -298,12 +362,54 @@ private void EnsureInitialized() vk.CreatePipelineLayout(device, in layoutInfo, null, out _customBlitLayout); } + // masked: set0 = content, set1 = mask, push constant = mat4 + vec4 (両ステージ, 80 bytes) + { + var twoTextureLayouts = stackalloc DescriptorSetLayout[2] { textureSetLayout, textureSetLayout }; + var pushConstant = new PushConstantRange( + ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit, + 0, + 80 + ); + var layoutInfo = new PipelineLayoutCreateInfo + { + SType = StructureType.PipelineLayoutCreateInfo, + SetLayoutCount = 2, + PSetLayouts = twoTextureLayouts, + PushConstantRangeCount = 1, + PPushConstantRanges = &pushConstant, + }; + vk.CreatePipelineLayout(device, in layoutInfo, null, out _maskedLayout); + } + + // stencil write: set0 = sampler, push constant = mat4 + vec4 (両ステージ, 80 bytes) + { + var pushConstant = new PushConstantRange( + ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit, + 0, + 80 + ); + var layoutInfo = new PipelineLayoutCreateInfo + { + SType = StructureType.PipelineLayoutCreateInfo, + SetLayoutCount = 1, + PSetLayouts = &textureSetLayout, + PushConstantRangeCount = 1, + PPushConstantRanges = &pushConstant, + }; + vk.CreatePipelineLayout(device, in layoutInfo, null, out _stencilWriteLayout); + } + _initialized = true; } - private Pipeline GetOrCreate(PipelineKind kind, PassClass pass, PrimitiveTopology topology) + private Pipeline GetOrCreate( + PipelineKind kind, + PassClass pass, + PrimitiveTopology topology, + StencilMode stencil + ) { - var key = (kind, pass, topology); + var key = (kind, pass, topology, stencil); if (_cache.TryGetValue(key, out var cached)) return cached; @@ -311,36 +417,64 @@ private Pipeline GetOrCreate(PipelineKind kind, PassClass pass, PrimitiveTopolog var pipeline = kind switch { PipelineKind.Texture => CreateEmbeddedPipeline( + "texture_instanced", "texture_instanced", _textureLayout, pass, PrimitiveTopology.TriangleList, enableBlend: true, - VertexLayout.InstancedSprite + VertexLayout.InstancedSprite, + stencil ), PipelineKind.Primitive => CreateEmbeddedPipeline( + "primitive", "primitive", _primitiveLayout, pass, topology, enableBlend: true, - VertexLayout.Position2D + VertexLayout.Position2D, + stencil ), PipelineKind.Blit => CreateEmbeddedPipeline( + "blit", "blit", _blitLayout, pass, PrimitiveTopology.TriangleList, enableBlend: false, - VertexLayout.None + VertexLayout.None, + stencil ), PipelineKind.Pie => CreateEmbeddedPipeline( + "pie", "pie", _pieLayout, pass, PrimitiveTopology.TriangleList, enableBlend: true, - VertexLayout.PositionUv + VertexLayout.PositionUv, + stencil + ), + PipelineKind.Masked => CreateEmbeddedPipeline( + "masked", + "masked", + _maskedLayout, + pass, + PrimitiveTopology.TriangleList, + enableBlend: true, + VertexLayout.PositionUv, + stencil + ), + PipelineKind.StencilWrite => CreateEmbeddedPipeline( + "masked", + "stencil_mask", + _stencilWriteLayout, + pass, + PrimitiveTopology.TriangleList, + enableBlend: false, + VertexLayout.PositionUv, + StencilMode.WriteMask ), _ => throw new ArgumentOutOfRangeException(nameof(kind)), }; @@ -348,9 +482,14 @@ private Pipeline GetOrCreate(PipelineKind kind, PassClass pass, PrimitiveTopolog return pipeline; } - private Pipeline GetOrCreateCustom(int shaderId, CustomKind kind, PassClass pass) + private Pipeline GetOrCreateCustom( + int shaderId, + CustomKind kind, + PassClass pass, + StencilMode stencil + ) { - var key = (shaderId, kind, pass); + var key = (shaderId, kind, pass, stencil); if (_customCache.TryGetValue(key, out var cached)) return cached; @@ -365,7 +504,8 @@ private Pipeline GetOrCreateCustom(int shaderId, CustomKind kind, PassClass pass pass, PrimitiveTopology.TriangleList, enableBlend: true, - VertexLayout.InstancedSprite + VertexLayout.InstancedSprite, + stencil ), CustomKind.Blit => CreatePipeline( entry.VertexModule, @@ -374,7 +514,8 @@ private Pipeline GetOrCreateCustom(int shaderId, CustomKind kind, PassClass pass pass, PrimitiveTopology.TriangleList, enableBlend: false, - VertexLayout.None + VertexLayout.None, + stencil ), _ => throw new ArgumentOutOfRangeException(nameof(kind)), }; @@ -386,23 +527,25 @@ private RenderPass GetRenderPass(PassClass pass) => pass == PassClass.Offscreen ? _ctx.OffscreenClearPass : _ctx.SwapchainPass; private Pipeline CreateEmbeddedPipeline( - string shaderName, + string vertexShaderName, + string fragmentShaderName, PipelineLayout layout, PassClass pass, PrimitiveTopology topology, bool enableBlend, - VertexLayout vertexLayout + VertexLayout vertexLayout, + StencilMode stencil ) { var vertSpv = _compiler.Compile( - EmbeddedResource.GetResourceAsString($"Promete.Resources.shaders.vulkan.{shaderName}.vert"), + EmbeddedResource.GetResourceAsString($"Promete.Resources.shaders.vulkan.{vertexShaderName}.vert"), ShaderKind.VertexShader, - $"{shaderName}.vert" + $"{vertexShaderName}.vert" ); var fragSpv = _compiler.Compile( - EmbeddedResource.GetResourceAsString($"Promete.Resources.shaders.vulkan.{shaderName}.frag"), + EmbeddedResource.GetResourceAsString($"Promete.Resources.shaders.vulkan.{fragmentShaderName}.frag"), ShaderKind.FragmentShader, - $"{shaderName}.frag" + $"{fragmentShaderName}.frag" ); var vertModule = CreateShaderModule(vertSpv); @@ -410,7 +553,7 @@ VertexLayout vertexLayout try { - return CreatePipeline(vertModule, fragModule, layout, pass, topology, enableBlend, vertexLayout); + return CreatePipeline(vertModule, fragModule, layout, pass, topology, enableBlend, vertexLayout, stencil); } finally { @@ -426,7 +569,8 @@ private Pipeline CreatePipeline( PassClass pass, PrimitiveTopology topology, bool enableBlend, - VertexLayout vertexLayout + VertexLayout vertexLayout, + StencilMode stencil ) { var vk = _ctx.Vk; @@ -524,6 +668,7 @@ VertexLayout vertexLayout RasterizationSamples = SampleCountFlags.Count1Bit, }; + // ステンシル書き込みモードではカラーバッファへ書き込まない var blendAttachment = new PipelineColorBlendAttachmentState { BlendEnable = enableBlend, @@ -534,10 +679,47 @@ VertexLayout vertexLayout DstAlphaBlendFactor = BlendFactor.OneMinusSrcAlpha, AlphaBlendOp = BlendOp.Add, ColorWriteMask = - ColorComponentFlags.RBit - | ColorComponentFlags.GBit - | ColorComponentFlags.BBit - | ColorComponentFlags.ABit, + stencil == StencilMode.WriteMask + ? 0 + : ColorComponentFlags.RBit + | ColorComponentFlags.GBit + | ColorComponentFlags.BBit + | ColorComponentFlags.ABit, + }; + + var stencilOp = stencil switch + { + StencilMode.WriteMask => new StencilOpState + { + FailOp = StencilOp.Keep, + PassOp = StencilOp.Replace, + DepthFailOp = StencilOp.Keep, + CompareOp = CompareOp.Always, + CompareMask = 0xFF, + WriteMask = 0xFF, + Reference = 1, + }, + StencilMode.TestEqual => new StencilOpState + { + FailOp = StencilOp.Keep, + PassOp = StencilOp.Keep, + DepthFailOp = StencilOp.Keep, + CompareOp = CompareOp.Equal, + CompareMask = 0xFF, + WriteMask = 0x00, + Reference = 1, + }, + _ => default, + }; + + var depthStencil = new PipelineDepthStencilStateCreateInfo + { + SType = StructureType.PipelineDepthStencilStateCreateInfo, + DepthTestEnable = false, + DepthWriteEnable = false, + StencilTestEnable = stencil != StencilMode.None, + Front = stencilOp, + Back = stencilOp, }; var colorBlend = new PipelineColorBlendStateCreateInfo @@ -570,6 +752,7 @@ VertexLayout vertexLayout PRasterizationState = &rasterization, PMultisampleState = &multisample, PColorBlendState = &colorBlend, + PDepthStencilState = pass == PassClass.Offscreen ? &depthStencil : null, PDynamicState = &dynamicState, Layout = layout, RenderPass = GetRenderPass(pass), diff --git a/Promete/Graphics/Rendering/Vulkan/VulkanRenderTarget.cs b/Promete/Graphics/Rendering/Vulkan/VulkanRenderTarget.cs index d9c52f5..d204ccb 100644 --- a/Promete/Graphics/Rendering/Vulkan/VulkanRenderTarget.cs +++ b/Promete/Graphics/Rendering/Vulkan/VulkanRenderTarget.cs @@ -16,6 +16,15 @@ internal sealed class VulkanRenderTarget /// イメージビュー。 public required ImageView View { get; set; } + /// ステンシルアタッチメントのイメージ。 + public required Image StencilImage { get; set; } + + /// ステンシルイメージのメモリ。 + public required DeviceMemory StencilMemory { get; set; } + + /// ステンシルイメージビュー。 + public required ImageView StencilView { get; set; } + /// オフスクリーンパス用フレームバッファ。 public required Framebuffer Framebuffer { get; set; } diff --git a/Promete/Graphics/Rendering/Vulkan/VulkanRenderTextureProvider.cs b/Promete/Graphics/Rendering/Vulkan/VulkanRenderTextureProvider.cs index b2ba9df..ca68bf6 100644 --- a/Promete/Graphics/Rendering/Vulkan/VulkanRenderTextureProvider.cs +++ b/Promete/Graphics/Rendering/Vulkan/VulkanRenderTextureProvider.cs @@ -44,6 +44,9 @@ public void Resize(RenderTexture renderTexture, VectorInt newSize) target.Image = newTarget.Image; target.Memory = newTarget.Memory; target.View = newTarget.View; + target.StencilImage = newTarget.StencilImage; + target.StencilMemory = newTarget.StencilMemory; + target.StencilView = newTarget.StencilView; target.Framebuffer = newTarget.Framebuffer; target.Extent = newTarget.Extent; @@ -71,12 +74,18 @@ public void Release(RenderTexture renderTexture) var view = target.View; var image = target.Image; var memory = target.Memory; + var stencilView = target.StencilView; + var stencilImage = target.StencilImage; + var stencilMemory = target.StencilMemory; ctx.DeferDestroy(() => { vk.DestroyFramebuffer(device, framebuffer, null); vk.DestroyImageView(device, view, null); vk.DestroyImage(device, image, null); vk.FreeMemory(device, memory, null); + vk.DestroyImageView(device, stencilView, null); + vk.DestroyImage(device, stencilImage, null); + vk.FreeMemory(device, stencilMemory, null); }); } @@ -114,7 +123,31 @@ private VulkanRenderTarget CreateTarget(VectorInt size, int? textureId) ); var view = ctx.CreateImageView2D(image, VulkanContext.OffscreenFormat); - var framebuffer = ctx.CreateOffscreenFramebuffer(view, width, height); + + // ステンシルアタッチメント + var (stencilImage, stencilMemory) = ctx.CreateImage2D( + width, + height, + ctx.StencilFormat, + ImageUsageFlags.DepthStencilAttachmentBit + ); + var stencilAspect = ImageAspectFlags.DepthBit | ImageAspectFlags.StencilBit; + ctx.ExecuteOneTime(cmd => + ctx.TransitionImageLayout( + cmd, + stencilImage, + ImageLayout.Undefined, + ImageLayout.DepthStencilAttachmentOptimal, + PipelineStageFlags.TopOfPipeBit, + 0, + PipelineStageFlags.EarlyFragmentTestsBit | PipelineStageFlags.LateFragmentTestsBit, + AccessFlags.DepthStencilAttachmentWriteBit | AccessFlags.DepthStencilAttachmentReadBit, + stencilAspect + ) + ); + var stencilView = ctx.CreateImageView2D(stencilImage, ctx.StencilFormat, stencilAspect); + + var framebuffer = ctx.CreateOffscreenFramebuffer(view, stencilView, width, height); int id; if (textureId is { } existingId) @@ -132,6 +165,9 @@ private VulkanRenderTarget CreateTarget(VectorInt size, int? textureId) Image = image, Memory = memory, View = view, + StencilImage = stencilImage, + StencilMemory = stencilMemory, + StencilView = stencilView, Framebuffer = framebuffer, Extent = new Extent2D(width, height), TextureId = id, @@ -146,6 +182,9 @@ private void DestroyTargetResources(VulkanRenderTarget target) vk.DestroyImageView(device, target.View, null); vk.DestroyImage(device, target.Image, null); vk.FreeMemory(device, target.Memory, null); + vk.DestroyImageView(device, target.StencilView, null); + vk.DestroyImage(device, target.StencilImage, null); + vk.FreeMemory(device, target.StencilMemory, null); } private sealed class CaptureScope(VulkanContext ctx) : IDisposable diff --git a/Promete/Resources/shaders/vulkan/masked.frag b/Promete/Resources/shaders/vulkan/masked.frag new file mode 100644 index 0000000..cbc91ee --- /dev/null +++ b/Promete/Resources/shaders/vulkan/masked.frag @@ -0,0 +1,25 @@ +#version 450 +layout(location = 0) in vec2 fUv; + +layout(push_constant) uniform PushConstants +{ + mat4 uMvp; + vec4 uTintColor; +}; + +layout(set = 0, binding = 0) uniform sampler2D uContent; // 子要素のテクスチャ +layout(set = 1, binding = 0) uniform sampler2D uMask; // マスクテクスチャ + +layout(location = 0) out vec4 FragColor; + +void main() +{ + vec4 content = texture(uContent, fUv); + vec4 mask = texture(uMask, fUv); + + // マスクのRGB値の平均(濃淡)でコンテンツのアルファを調整 + float gray = (mask.r + mask.g + mask.b) / 3.0; + + FragColor = content * uTintColor; + FragColor.a *= gray; +} diff --git a/Promete/Resources/shaders/vulkan/masked.vert b/Promete/Resources/shaders/vulkan/masked.vert new file mode 100644 index 0000000..df330fb --- /dev/null +++ b/Promete/Resources/shaders/vulkan/masked.vert @@ -0,0 +1,17 @@ +#version 450 +layout(location = 0) in vec2 vPos; +layout(location = 1) in vec2 vUv; + +layout(location = 0) out vec2 fUv; + +layout(push_constant) uniform PushConstants +{ + mat4 uMvp; + vec4 uTintColor; +}; + +void main() +{ + gl_Position = uMvp * vec4(vPos, 0.0, 1.0); + fUv = vUv; +} diff --git a/Promete/Resources/shaders/vulkan/stencil_mask.frag b/Promete/Resources/shaders/vulkan/stencil_mask.frag new file mode 100644 index 0000000..ff634b2 --- /dev/null +++ b/Promete/Resources/shaders/vulkan/stencil_mask.frag @@ -0,0 +1,25 @@ +#version 450 +layout(location = 0) in vec2 fUv; + +layout(push_constant) uniform PushConstants +{ + mat4 uMvp; + vec4 uTintColor; +}; + +layout(set = 0, binding = 0) uniform sampler2D uTexture0; + +layout(location = 0) out vec4 FragColor; + +void main() +{ + vec4 texColor = texture(uTexture0, fUv) * uTintColor; + + // RGB値の平均(濃淡)が0.5以下の場合は破棄(ステンシルバッファに書き込まない) + float gray = (texColor.r + texColor.g + texColor.b) / 3.0; + if (gray <= 0.5) + discard; + + // カラーバッファには書き込まない(パイプラインの colorWriteMask で無効化) + FragColor = vec4(1.0); +} diff --git a/VULKAN_PORTING_PLAN.md b/VULKAN_PORTING_PLAN.md index 9d1b7df..acf77d5 100644 --- a/VULKAN_PORTING_PLAN.md +++ b/VULKAN_PORTING_PLAN.md @@ -92,7 +92,8 @@ OpenGL 依存コードは以下の 16 ファイル・約 2,600 行に限定さ ### Phase 3: ランナー移植 🚧 主要部分実装済み 実装済み: `VulkanDrawTextureBatchedCommandRunner` (インスタンシング + per-frame アリーナ + カスタムマテリアル対応)、`VulkanDrawPrimitiveCommandRunner`、`VulkanBeginTrim/EndTrimCommandRunner`、`VulkanDrawPieTextureCommandRunner` (push constant で MVP/tint/角度)、`VulkanScreenBlitter` (ピンポンポストプロセス + 最終ブリット)。スクリーンショット (`TakeScreenshot`/`SaveScreenshotAsync`) はポストプロセス適用後の最終ブリット元を読み出す。Promete.Experimental.Vulkan によるピクセル単位の自動検証 13 項目(スプライト/プリミティブ/ティント/FrameBuffer/PieSprite/カスタムマテリアル/色反転ポストプロセス)がパスしている。 -未実装: マスク系 (stencil/alpha — オフスクリーンパスへのステンシルアタッチメント追加が必要)、PieSprite のカスタムマテリアル、Material の Texture2D Uniform、線幅 >1 の線 (wideLines)。 +マスク系も実装済み: オフスクリーンパス/RT にステンシルアタッチメント (D24S8/D32S8 をフォーマット照会で選択) を追加し、ステンシルマスクはパイプラインバリアント (None/WriteMask/TestEqual) + `vkCmdClearAttachments` で実現。アルファマスクは `VulkanMaskedContainerHelper` によるサブレンダリング + 2テクスチャ合成 (set0=content, set1=mask として既存のテクスチャ毎ディスクリプタセットを流用)。 +未実装 (小物): PieSprite のカスタムマテリアル、Material の Texture2D Uniform、線幅 >1 の線 (wideLines)、ImGui。 元の計画(規模目安: 2,000 行): From dd000764ccff0afb7b42ca3f807fc6b9f288601d Mon Sep 17 00:00:00 2001 From: Ebise Lutica <7106976+EbiseLutica@users.noreply.github.com> Date: Fri, 24 Jul 2026 02:47:55 +0900 Subject: [PATCH 11/16] =?UTF-8?q?feat(Vulkan):=20Pie/Primitive=E3=82=AB?= =?UTF-8?q?=E3=82=B9=E3=82=BF=E3=83=A0=E3=83=9E=E3=83=86=E3=83=AA=E3=82=A2?= =?UTF-8?q?=E3=83=AB=E3=83=BBTexture2D=20Uniform=E3=81=AB=E5=AF=BE?= =?UTF-8?q?=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GL バックエンドとの機能パリティを完成させる: - SpirvReflector: サンプラー変数 (名前/set/binding) の抽出を追加 - カスタムパイプラインレイアウトをシェーダー毎に生成 (追加テクスチャのセット数をリフレクションから決定) - Material の Texture2D Uniform: set=2 以降の同名 sampler2D に 既存のテクスチャ毎ディスクリプタセットをバインド - VulkanDrawPieTextureCommandRunner / VulkanDrawPrimitiveCommandRunner に カスタムマテリアル描画パスを追加 - Experimental: 検証を20項目に拡張 — 全パス スコープ外と決定: 線幅>1 (wideLines非依存の展開は将来検討)、ImGui Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016A9ZxvQBn4vAu8Erah3rHr --- Promete.Experimental.Vulkan/MainScene.cs | 112 ++++++++++ .../Backends/Vulkan/VulkanDesktopBackend.cs | 12 +- .../VulkanDrawPieTextureCommandRunner.cs | 39 ++-- .../VulkanDrawPrimitiveCommandRunner.cs | 89 +++++--- .../VulkanDrawTextureBatchedCommandRunner.cs | 7 +- .../Rendering/Vulkan/SpirvReflector.cs | 61 +++++- .../Rendering/Vulkan/VulkanMaterialSystem.cs | 121 ++++++++--- .../Vulkan/VulkanPipelineProvider.cs | 204 +++++++++++------- .../Rendering/Vulkan/VulkanScreenBlitter.cs | 8 +- .../Rendering/Vulkan/VulkanShaderManager.cs | 35 ++- VULKAN_PORTING_PLAN.md | 3 +- 11 files changed, 524 insertions(+), 167 deletions(-) diff --git a/Promete.Experimental.Vulkan/MainScene.cs b/Promete.Experimental.Vulkan/MainScene.cs index 2da8c6a..addcdee 100644 --- a/Promete.Experimental.Vulkan/MainScene.cs +++ b/Promete.Experimental.Vulkan/MainScene.cs @@ -57,6 +57,68 @@ void main() } """; + private const string PrimitiveVertexShader = """ + #version 450 + layout(location = 0) in vec2 vPos; + void main() + { + gl_Position = vec4(vPos, 0.0, 1.0); + } + """; + + private const string PrimitiveUboFragmentShader = """ + #version 450 + layout(set = 1, binding = 0) uniform Uniforms { vec4 uFillColor; }; + layout(location = 0) out vec4 FragColor; + void main() + { + FragColor = uFillColor; + } + """; + + private const string ExtraTextureFragmentShader = """ + #version 450 + layout(location = 0) in vec2 fUv; + layout(location = 1) in vec4 fTintColor; + layout(set = 0, binding = 0) uniform sampler2D uTexture0; + layout(set = 2, binding = 0) uniform sampler2D uExtraTexture; + layout(location = 0) out vec4 FragColor; + void main() + { + FragColor = texture(uTexture0, fUv) * texture(uExtraTexture, fUv) * fTintColor; + } + """; + + private const string PieVertexShader = """ + #version 450 + layout(location = 0) in vec2 vPos; + layout(location = 1) in vec2 vUv; + layout(location = 0) out vec2 fUv; + layout(push_constant) uniform PushConstants + { + mat4 uMvp; + vec4 uTintColor; + vec2 uAngles; + }; + void main() + { + gl_Position = uMvp * vec4(vPos, 0.0, 1.0); + fUv = vUv; + } + """; + + private const string PieUboFragmentShader = """ + #version 450 + layout(location = 0) in vec2 fUv; + layout(set = 0, binding = 0) uniform sampler2D uTexture0; + layout(set = 1, binding = 0) uniform Uniforms { vec4 uPieColor; }; + layout(location = 0) out vec4 FragColor; + void main() + { + FragColor = texture(uTexture0, fUv) * uPieColor; + } + """; + private const string BlitVertexShader = """ #version 450 layout(location = 0) out vec2 fUv; @@ -84,6 +146,9 @@ void main() private FrameBuffer? _frameBuffer; private ShaderProgram? _overrideShader; private ShaderProgram? _invertShader; + private ShaderProgram? _primitiveShader; + private ShaderProgram? _extraTextureShader; + private ShaderProgram? _pieShader; private int _frameCount; private int _phase; private int _failures; @@ -145,6 +210,47 @@ public override void OnStart() alphaMasked.Add(new Sprite(pink)); Root.Add(alphaMasked); + // Primitive カスタムマテリアル検証: UBO の uFillColor で塗る矩形 + _primitiveShader = ShaderProgram + .Create() + .Vertex(PrimitiveVertexShader) + .Fragment(PrimitiveUboFragmentShader) + .Compile(); + var primitiveMaterial = new Material(_primitiveShader); + primitiveMaterial["uFillColor"] = new Vector4(0.5f, 0f, 0.5f, 1f); // (128, 0, 128) + var customRect = Shape.CreateRect(50, 430, 110, 470, Color.White); + customRect.Material = primitiveMaterial; + Root.Add(customRect); + + // Texture2D Uniform 検証: 白スプライト × 追加テクスチャ (緑, set=2) + _extraTextureShader = ShaderProgram + .Create() + .Vertex(InstancedVertexShader) + .Fragment(ExtraTextureFragmentShader) + .Compile(); + var extraMaterial = new Material(_extraTextureShader); + extraMaterial["uExtraTexture"] = App.TextureFactory.CreateSolid(Color.Lime, (40, 40)); + var extraSprite = new Sprite(App.TextureFactory.CreateSolid(Color.White, (40, 40))).Location(560, 50); + extraSprite.Material = extraMaterial; + Root.Add(extraSprite); + + // PieSprite カスタムマテリアル検証: UBO の uPieColor で塗る (全周) + _pieShader = ShaderProgram + .Create() + .Vertex(PieVertexShader) + .Fragment(PieUboFragmentShader) + .Compile(); + var pieMaterial = new Material(_pieShader); + pieMaterial["uPieColor"] = new Vector4(1f, 0.08f, 0.58f, 1f); // DeepPink (255, 20, 147) + var customPie = new PieSprite(App.TextureFactory.CreateSolid(Color.White, (30, 30))) + { + StartPercent = 0, + Percent = 100, + }; + customPie.Location = (600, 240); + customPie.Material = pieMaterial; + Root.Add(customPie); + // フェーズ2用: 色反転ポストプロセスシェーダー _invertShader = ShaderProgram .Create() @@ -177,6 +283,9 @@ public override void OnDestroy() _frameBuffer?.Dispose(); _overrideShader?.Dispose(); _invertShader?.Dispose(); + _primitiveShader?.Dispose(); + _extraTextureShader?.Dispose(); + _pieShader?.Dispose(); _redTexture.Dispose(); Console.WriteLine("[MainScene] OnDestroy"); } @@ -203,6 +312,9 @@ private async Task RunPhase1Async() _failures += Verify(img, 530, 350, Color.DarkSlateBlue, "ステンシルマスク 右半分 (非表示)"); _failures += Verify(img, 575, 410, Color.HotPink, "アルファマスク 左半分 (表示)"); _failures += Verify(img, 605, 410, Color.DarkSlateBlue, "アルファマスク 右半分 (非表示)"); + _failures += Verify(img, 80, 450, Color.FromArgb(128, 0, 128), "Primitive カスタムマテリアル (uFillColor)"); + _failures += Verify(img, 580, 70, Color.Lime, "Texture2D Uniform (uExtraTexture)"); + _failures += Verify(img, 615, 255, Color.FromArgb(255, 20, 148), "Pie カスタムマテリアル (uPieColor)"); // フェーズ2: 色反転ポストプロセスを適用 App.PostProcessMaterials.Add(new Material(_invertShader!)); diff --git a/Promete/Backends/Vulkan/VulkanDesktopBackend.cs b/Promete/Backends/Vulkan/VulkanDesktopBackend.cs index ed35788..e290495 100644 --- a/Promete/Backends/Vulkan/VulkanDesktopBackend.cs +++ b/Promete/Backends/Vulkan/VulkanDesktopBackend.cs @@ -67,7 +67,7 @@ public override void OnInitialize(PrometeApp app, WindowOptions opts) _context = new VulkanContext(_nativeWindow); _resources = new VulkanResourceManager(_context); _shaderManager = new VulkanShaderManager(_context); - _materialSystem = new VulkanMaterialSystem(_context, _shaderManager); + _materialSystem = new VulkanMaterialSystem(_context, _shaderManager, _resources); _pipelines = new VulkanPipelineProvider(_context, _resources, _shaderManager, _materialSystem); _time = new SilkNetCommonTimeProvider(_nativeWindow); _gameView = new VulkanDesktopGameView(_app, _nativeWindow); @@ -128,7 +128,13 @@ private void OnLoad() _shaderManager, _materialSystem ); - _pieRunner = new VulkanDrawPieTextureCommandRunner(_context, _resources, _pipelines); + _pieRunner = new VulkanDrawPieTextureCommandRunner( + _context, + _resources, + _pipelines, + _shaderManager, + _materialSystem + ); var queue = _app.GetPlugin(); _maskHelper = new VulkanMaskedContainerHelper( _app, @@ -141,7 +147,7 @@ private void OnLoad() queue.RegisterRunnerRange( _textureRunner, _pieRunner, - new VulkanDrawPrimitiveCommandRunner(_context, _pipelines), + new VulkanDrawPrimitiveCommandRunner(_context, _pipelines, _shaderManager, _materialSystem), new VulkanBeginTrimCommandRunner(_context), new VulkanEndTrimCommandRunner(_context), new VulkanBeginStencilMaskCommandRunner(_context, _maskHelper), diff --git a/Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawPieTextureCommandRunner.cs b/Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawPieTextureCommandRunner.cs index 51dc7db..3275039 100644 --- a/Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawPieTextureCommandRunner.cs +++ b/Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawPieTextureCommandRunner.cs @@ -14,7 +14,9 @@ namespace Promete.Graphics.Rendering.Vulkan.Runners; internal sealed unsafe class VulkanDrawPieTextureCommandRunner( VulkanContext ctx, VulkanResourceManager resources, - VulkanPipelineProvider pipelines + VulkanPipelineProvider pipelines, + VulkanShaderManager shaders, + VulkanMaterialSystem materials ) : CommandRunner, IDisposable { // push constant: mat4 (16) + vec4 tint (4) + vec2 angles (2) + padding (2) = 24 floats @@ -69,11 +71,14 @@ private void Draw( if (!ctx.IsFrameActive || !resources.Contains(texture.Handle)) return; - // TODO: カスタムマテリアルの適用に対応する - if (material is not null && !_materialWarned) + // カスタムマテリアル: シェーダーがコンパイル済みならカスタムパイプラインを使用 + var useCustom = material is not null && shaders.Contains(material.Shader.Handle); + if (material is not null && !useCustom && !_materialWarned) { _materialWarned = true; - LogHelper.Bug("Vulkan バックエンドはまだ PieSprite のカスタムマテリアルをサポートしていません。"); + LogHelper.Bug( + "Material のシェーダーがコンパイルされていないため、デフォルトシェーダーで描画します。" + ); } EnsureInitialized(); @@ -109,16 +114,23 @@ private void Draw( var vk = ctx.Vk; var cmd = ctx.CurrentCommandBuffer; - var pipeline = pipelines.GetPiePipeline( - VulkanPipelineProvider.PassClass.Offscreen, - ctx.StencilMaskActive - ? VulkanPipelineProvider.StencilMode.TestEqual - : VulkanPipelineProvider.StencilMode.None - ); + var stencil = ctx.StencilMaskActive + ? VulkanPipelineProvider.StencilMode.TestEqual + : VulkanPipelineProvider.StencilMode.None; + var pipeline = useCustom + ? pipelines.GetCustomPiePipeline( + material!.Shader.Handle, + VulkanPipelineProvider.PassClass.Offscreen, + stencil + ) + : pipelines.GetPiePipeline(VulkanPipelineProvider.PassClass.Offscreen, stencil); + var layout = useCustom + ? pipelines.GetCustomLayout(material!.Shader.Handle, VulkanPipelineProvider.CustomKind.Pie) + : pipelines.PieLayout; vk.CmdBindPipeline(cmd, PipelineBindPoint.Graphics, pipeline); vk.CmdPushConstants( cmd, - pipelines.PieLayout, + layout, ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit, 0, PushConstantFloats * sizeof(float), @@ -129,7 +141,7 @@ private void Draw( vk.CmdBindDescriptorSets( cmd, PipelineBindPoint.Graphics, - pipelines.PieLayout, + layout, 0, 1, in descriptorSet, @@ -137,6 +149,9 @@ private void Draw( null ); + if (useCustom) + materials.Apply(cmd, material!, layout); + var offset = 0ul; vk.CmdBindVertexBuffers(cmd, 0, 1, in _quadVbo, in offset); vk.CmdBindIndexBuffer(cmd, _quadEbo, 0, IndexType.Uint32); diff --git a/Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawPrimitiveCommandRunner.cs b/Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawPrimitiveCommandRunner.cs index d216c0f..9276c8f 100644 --- a/Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawPrimitiveCommandRunner.cs +++ b/Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawPrimitiveCommandRunner.cs @@ -12,7 +12,9 @@ namespace Promete.Graphics.Rendering.Vulkan.Runners; /// internal sealed unsafe class VulkanDrawPrimitiveCommandRunner( VulkanContext ctx, - VulkanPipelineProvider pipelines + VulkanPipelineProvider pipelines, + VulkanShaderManager shaders, + VulkanMaterialSystem materials ) : CommandRunner { public override void Execute(DrawPrimitiveCommand command) @@ -22,7 +24,8 @@ public override void Execute(DrawPrimitiveCommand command) command.ShapeType, command.Color, command.LineWidth, - command.LineColor + command.LineColor, + command.Material ); } @@ -31,7 +34,8 @@ private void Draw( ShapeType type, Color color, int lineWidth, - Color? lineColor + Color? lineColor, + Material? material ) { PrometeApp.Current.ThrowIfNotMainThread(); @@ -51,11 +55,17 @@ private void Draw( vertices[(i * 2) + 1] = -y; } - DrawFill(vertices, type, color, lineWidth); - DrawStroke(vertices, lineWidth, lineColor); + DrawFill(vertices, type, color, lineWidth, material); + DrawStroke(vertices, lineWidth, lineColor, material); } - private void DrawFill(Span vertices, ShapeType type, Color color, int lineWidth) + private void DrawFill( + Span vertices, + ShapeType type, + Color color, + int lineWidth, + Material? material + ) { // 透明度が0の場合は、塗りつぶし領域の描画をスキップする if (color.A <= 0) @@ -68,14 +78,7 @@ private void DrawFill(Span vertices, ShapeType type, Color color, int lin var vertexCount = (uint)(vertices.Length / 2); var (buffer, offset) = ctx.CurrentArena.Push(vertices); - - var pipeline = pipelines.GetPrimitivePipeline( - VulkanPipelineProvider.PassClass.Offscreen, - ToVulkanTopology(type), - CurrentStencilMode() - ); - vk.CmdBindPipeline(cmd, PipelineBindPoint.Graphics, pipeline); - PushColor(cmd, color); + BindPipeline(cmd, ToVulkanTopology(type), color, material); vk.CmdBindVertexBuffers(cmd, 0, 1, in buffer, in offset); @@ -92,7 +95,7 @@ private void DrawFill(Span vertices, ShapeType type, Color color, int lin vk.CmdDraw(cmd, vertexCount, 1, 0, 0); } - private void DrawStroke(Span vertices, int lineWidth, Color? lineColor) + private void DrawStroke(Span vertices, int lineWidth, Color? lineColor, Material? material) { if (lineWidth <= 0 || lineColor is not { } lc) return; @@ -107,30 +110,52 @@ private void DrawStroke(Span vertices, int lineWidth, Color? lineColor) looped[^1] = vertices[1]; var (buffer, offset) = ctx.CurrentArena.Push(looped); - - var pipeline = pipelines.GetPrimitivePipeline( - VulkanPipelineProvider.PassClass.Offscreen, - PrimitiveTopology.LineStrip, - CurrentStencilMode() - ); - vk.CmdBindPipeline(cmd, PipelineBindPoint.Graphics, pipeline); - PushColor(cmd, lc); + BindPipeline(cmd, PrimitiveTopology.LineStrip, lc, material); vk.CmdBindVertexBuffers(cmd, 0, 1, in buffer, in offset); vk.CmdDraw(cmd, (uint)(looped.Length / 2), 1, 0, 0); } - private void PushColor(CommandBuffer cmd, Color color) + /// + /// パイプラインをバインドし、色の push constant とマテリアルを適用します。 + /// + private void BindPipeline( + CommandBuffer cmd, + PrimitiveTopology topology, + Color color, + Material? material + ) { + var vk = ctx.Vk; + var stencil = CurrentStencilMode(); + var useCustom = material is not null && shaders.Contains(material.Shader.Handle); + + var pipeline = useCustom + ? pipelines.GetCustomPrimitivePipeline( + material!.Shader.Handle, + VulkanPipelineProvider.PassClass.Offscreen, + topology, + stencil + ) + : pipelines.GetPrimitivePipeline( + VulkanPipelineProvider.PassClass.Offscreen, + topology, + stencil + ); + var layout = useCustom + ? pipelines.GetCustomLayout( + material!.Shader.Handle, + VulkanPipelineProvider.CustomKind.Primitive + ) + : pipelines.PrimitiveLayout; + + vk.CmdBindPipeline(cmd, PipelineBindPoint.Graphics, pipeline); + var value = new Vector4(color.R / 255f, color.G / 255f, color.B / 255f, color.A / 255f); - ctx.Vk.CmdPushConstants( - cmd, - pipelines.PrimitiveLayout, - ShaderStageFlags.FragmentBit, - 0, - 16, - &value - ); + vk.CmdPushConstants(cmd, layout, ShaderStageFlags.FragmentBit, 0, 16, &value); + + if (useCustom) + materials.Apply(cmd, material!, layout); } private VulkanPipelineProvider.StencilMode CurrentStencilMode() => diff --git a/Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawTextureBatchedCommandRunner.cs b/Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawTextureBatchedCommandRunner.cs index 780f6be..e2057f7 100644 --- a/Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawTextureBatchedCommandRunner.cs +++ b/Promete/Graphics/Rendering/Vulkan/Runners/VulkanDrawTextureBatchedCommandRunner.cs @@ -149,7 +149,12 @@ private void DrawInstanced(List items, Material? material) stencil ) : _pipelines.GetTexturePipeline(VulkanPipelineProvider.PassClass.Offscreen, stencil); - var layout = useCustom ? _pipelines.CustomSpriteLayout : _pipelines.TextureLayout; + var layout = useCustom + ? _pipelines.GetCustomLayout( + material!.Shader.Handle, + VulkanPipelineProvider.CustomKind.Sprite + ) + : _pipelines.TextureLayout; vk.CmdBindPipeline(cmdBuffer, PipelineBindPoint.Graphics, pipeline); // プロジェクション行列 (Vulkan は NDC が Y 下向きなので bottom=0, top=height) diff --git a/Promete/Graphics/Rendering/Vulkan/SpirvReflector.cs b/Promete/Graphics/Rendering/Vulkan/SpirvReflector.cs index e686d75..bd9bc04 100644 --- a/Promete/Graphics/Rendering/Vulkan/SpirvReflector.cs +++ b/Promete/Graphics/Rendering/Vulkan/SpirvReflector.cs @@ -10,7 +10,10 @@ namespace Promete.Graphics.Rendering.Vulkan; /// internal static class SpirvReflector { + private const uint OpName = 5; private const uint OpMemberName = 6; + private const uint OpTypeImage = 25; + private const uint OpTypeSampledImage = 27; private const uint OpTypeStruct = 30; private const uint OpTypePointer = 32; private const uint OpVariable = 59; @@ -21,12 +24,19 @@ internal static class SpirvReflector private const uint DecorationDescriptorSet = 34; private const uint DecorationOffset = 35; + private const uint StorageClassUniformConstant = 0; private const uint StorageClassUniform = 2; /// /// SPIR-V から Uniform ブロック (storage class Uniform) の一覧を抽出します。 /// - public static List ReflectUniformBlocks(byte[] spirv) + public static List ReflectUniformBlocks(byte[] spirv) => + Reflect(spirv).Blocks; + + /// + /// SPIR-V から Uniform ブロックとサンプラー変数の一覧を抽出します。 + /// + public static (List Blocks, List Samplers) Reflect(byte[] spirv) { var words = new uint[spirv.Length / 4]; System.Buffer.BlockCopy(spirv, 0, words, 0, words.Length * 4); @@ -35,12 +45,15 @@ public static List ReflectUniformBlocks(byte[] spirv) throw new InvalidOperationException("不正な SPIR-V バイナリです。"); // 収集用テーブル + var names = new Dictionary(); var memberNames = new Dictionary<(uint TypeId, uint Member), string>(); var memberOffsets = new Dictionary<(uint TypeId, uint Member), uint>(); var decorations = new Dictionary<(uint Id, uint Decoration), uint>(); var structTypes = new HashSet(); + var imageTypes = new HashSet(); var pointerTargets = new Dictionary(); var uniformVariables = new List<(uint Id, uint PointerTypeId)>(); + var samplerVariables = new List<(uint Id, uint PointerTypeId)>(); var index = 5; while (index < words.Length) @@ -52,6 +65,9 @@ public static List ReflectUniformBlocks(byte[] spirv) switch (opcode) { + case OpName: + names[words[index + 1]] = ReadString(words, index + 2, index + wordCount); + break; case OpMemberName: memberNames[(words[index + 1], words[index + 2])] = ReadString(words, index + 3, index + wordCount); break; @@ -64,12 +80,19 @@ public static List ReflectUniformBlocks(byte[] spirv) case OpTypeStruct: structTypes.Add(words[index + 1]); break; + case OpTypeImage: + case OpTypeSampledImage: + imageTypes.Add(words[index + 1]); + break; case OpTypePointer: pointerTargets[words[index + 1]] = (words[index + 2], words[index + 3]); break; case OpVariable when words[index + 3] == StorageClassUniform: uniformVariables.Add((words[index + 2], words[index + 1])); break; + case OpVariable when words[index + 3] == StorageClassUniformConstant: + samplerVariables.Add((words[index + 2], words[index + 1])); + break; } index += wordCount; @@ -107,7 +130,26 @@ public static List ReflectUniformBlocks(byte[] spirv) ); } - return blocks; + // サンプラー変数 (combined image sampler) を解決 + var samplers = new List(); + foreach (var (variableId, pointerTypeId) in samplerVariables) + { + if (!pointerTargets.TryGetValue(pointerTypeId, out var pointer)) + continue; + if (!imageTypes.Contains(pointer.TypeId)) + continue; + + samplers.Add( + new SamplerBinding + { + Set = decorations.GetValueOrDefault((variableId, DecorationDescriptorSet)), + Binding = decorations.GetValueOrDefault((variableId, DecorationBinding)), + Name = names.GetValueOrDefault(variableId, string.Empty), + } + ); + } + + return (blocks, samplers); } private static string ReadString(uint[] words, int start, int end) @@ -145,4 +187,19 @@ public sealed class UniformBlock /// ブロックの最低サイズ(最大オフセット + 64 バイトの余裕)。 public uint Size { get; init; } } + + /// + /// リフレクションで得られたサンプラー変数の情報です。 + /// + public sealed class SamplerBinding + { + /// ディスクリプタセット番号。 + public uint Set { get; init; } + + /// バインディング番号。 + public uint Binding { get; init; } + + /// 変数名。 + public required string Name { get; init; } + } } diff --git a/Promete/Graphics/Rendering/Vulkan/VulkanMaterialSystem.cs b/Promete/Graphics/Rendering/Vulkan/VulkanMaterialSystem.cs index 509b106..a3b65c7 100644 --- a/Promete/Graphics/Rendering/Vulkan/VulkanMaterialSystem.cs +++ b/Promete/Graphics/Rendering/Vulkan/VulkanMaterialSystem.cs @@ -17,6 +17,7 @@ internal sealed unsafe class VulkanMaterialSystem : IDisposable private readonly VulkanContext _ctx; private readonly VulkanShaderManager _shaders; + private readonly VulkanResourceManager _resources; private readonly Dictionary<(Material Material, int Slot), MaterialSlot> _slots = []; private readonly HashSet _warnedUniforms = []; @@ -25,10 +26,15 @@ internal sealed unsafe class VulkanMaterialSystem : IDisposable private bool _initialized; private bool _disposed; - public VulkanMaterialSystem(VulkanContext ctx, VulkanShaderManager shaders) + public VulkanMaterialSystem( + VulkanContext ctx, + VulkanShaderManager shaders, + VulkanResourceManager resources + ) { _ctx = ctx; _shaders = shaders; + _resources = resources; } /// Uniform ブロック (set=1) 用のディスクリプタセットレイアウトを取得します。 @@ -42,43 +48,98 @@ public DescriptorSetLayout UboSetLayout } /// - /// マテリアルの Uniform 値を UBO へ書き込み、set=1 としてバインドします。 - /// シェーダーに Uniform ブロックが無い場合は何もしません。 + /// マテリアルの Uniform 値を適用します。 + /// スカラー/ベクトル値は UBO へ書き込み set=1 としてバインドし、 + /// Texture2D 値はシェーダーの同名サンプラー (set >= 2) にバインドします。 /// public void Apply(CommandBuffer cmd, Material material, PipelineLayout pipelineLayout) { var entry = _shaders.Get(material.Shader.Handle); - if (entry.UniformBlock is not { } block) - return; - - EnsureInitialized(); - var slotKey = (material, _ctx.FrameIndex); - if (!_slots.TryGetValue(slotKey, out var slot)) + if (entry.UniformBlock is { } block) { - slot = CreateSlot(block.Size); - _slots[slotKey] = slot; + EnsureInitialized(); + + var slotKey = (material, _ctx.FrameIndex); + if (!_slots.TryGetValue(slotKey, out var slot)) + { + slot = CreateSlot(block.Size); + _slots[slotKey] = slot; + } + + // Uniform 値をオフセットに従って書き込む + foreach (var (name, value) in material.Uniforms) + { + if (!block.MemberOffsets.TryGetValue(name, out var offset)) + continue; + WriteValue(slot.Mapped + offset, value); + } + + var set = slot.Set; + _ctx.Vk.CmdBindDescriptorSets( + cmd, + PipelineBindPoint.Graphics, + pipelineLayout, + 1, + 1, + in set, + 0, + null + ); } - // Uniform 値をオフセットに従って書き込む + BindTextureUniforms(cmd, material, entry, pipelineLayout); + } + + /// + /// Material の Texture2D Uniform を、シェーダーの同名サンプラー (set >= 2) にバインドします。 + /// + private void BindTextureUniforms( + CommandBuffer cmd, + Material material, + VulkanShaderManager.VulkanShaderEntry entry, + PipelineLayout pipelineLayout + ) + { foreach (var (name, value) in material.Uniforms) { - if (!block.MemberOffsets.TryGetValue(name, out var offset)) + if (value is not Texture2D texture) continue; - WriteValue(slot.Mapped + offset, value, name); - } - var set = slot.Set; - _ctx.Vk.CmdBindDescriptorSets( - cmd, - PipelineBindPoint.Graphics, - pipelineLayout, - 1, - 1, - in set, - 0, - null - ); + SpirvReflector.SamplerBinding? sampler = null; + foreach (var s in entry.Samplers) + { + if (s.Set >= 2 && s.Name == name) + { + sampler = s; + break; + } + } + + if (sampler is null) + { + if (_warnedUniforms.Add(name)) + LogHelper.Bug( + $"Material の Texture2D Uniform ({name}) に対応するサンプラーがシェーダーにありません。set=2 以降に同名の sampler2D を宣言してください。" + ); + continue; + } + + if (!_resources.Contains(texture.Handle)) + continue; + + var textureSet = _resources.GetDescriptorSet(texture.Handle); + _ctx.Vk.CmdBindDescriptorSets( + cmd, + PipelineBindPoint.Graphics, + pipelineLayout, + sampler.Set, + 1, + in textureSet, + 0, + null + ); + } } public void Dispose() @@ -197,7 +258,7 @@ private MaterialSlot CreateSlot(uint size) }; } - private void WriteValue(byte* dst, object value, string name) + private static void WriteValue(byte* dst, object value) { switch (value) { @@ -225,12 +286,6 @@ private void WriteValue(byte* dst, object value, string name) case Matrix4x4 m: *(Matrix4x4*)dst = m; break; - case Texture2D: - if (_warnedUniforms.Add(name)) - LogHelper.Bug( - $"Vulkan バックエンドはまだ Material の Texture2D Uniform ({name}) をサポートしていません。" - ); - break; } } diff --git a/Promete/Graphics/Rendering/Vulkan/VulkanPipelineProvider.cs b/Promete/Graphics/Rendering/Vulkan/VulkanPipelineProvider.cs index ff0a30e..153d933 100644 --- a/Promete/Graphics/Rendering/Vulkan/VulkanPipelineProvider.cs +++ b/Promete/Graphics/Rendering/Vulkan/VulkanPipelineProvider.cs @@ -22,18 +22,18 @@ private readonly Dictionary< Pipeline > _cache = []; private readonly Dictionary< - (int ShaderId, CustomKind Kind, PassClass Pass, StencilMode Stencil), + (int ShaderId, CustomKind Kind, PassClass Pass, StencilMode Stencil, PrimitiveTopology Topology), Pipeline > _customCache = []; + private readonly Dictionary<(int ShaderId, CustomKind Kind), PipelineLayout> _customLayoutCache = []; private PipelineLayout _textureLayout; private PipelineLayout _primitiveLayout; private PipelineLayout _blitLayout; private PipelineLayout _pieLayout; - private PipelineLayout _customSpriteLayout; - private PipelineLayout _customBlitLayout; private PipelineLayout _maskedLayout; private PipelineLayout _stencilWriteLayout; + private DescriptorSetLayout _emptySetLayout; private bool _initialized; private bool _disposed; @@ -65,6 +65,12 @@ public enum CustomKind /// フルスクリーンブリット用 (頂点入力なし)。 Blit, + + /// 扇形テクスチャ用 (pie 互換の頂点レイアウトと push constant)。 + Pie, + + /// プリミティブ用 (primitive 互換の頂点レイアウトと push constant)。 + Primitive, } /// ステンシルの動作モード。 @@ -138,25 +144,6 @@ public PipelineLayout PieLayout } } - /// カスタムスプライトシェーダー用のパイプラインレイアウトを取得します。 - public PipelineLayout CustomSpriteLayout - { - get - { - EnsureInitialized(); - return _customSpriteLayout; - } - } - - /// カスタムブリットシェーダー用のパイプラインレイアウトを取得します。 - public PipelineLayout CustomBlitLayout - { - get - { - EnsureInitialized(); - return _customBlitLayout; - } - } /// アルファマスク合成用のパイプラインレイアウトを取得します。 public PipelineLayout MaskedLayout @@ -178,6 +165,23 @@ public PipelineLayout StencilWriteLayout } } + /// + /// カスタムシェーダー用のパイプラインレイアウトを取得します。 + /// シェーダーのリフレクション結果 (追加テクスチャのセット数) に基づいて生成・キャッシュされます。 + /// + public PipelineLayout GetCustomLayout(int shaderId, CustomKind kind) + { + var key = (shaderId, kind); + if (_customLayoutCache.TryGetValue(key, out var cached)) + return cached; + + EnsureInitialized(); + var entry = _shaders.Get(shaderId); + var layout = CreateCustomLayout(kind, entry.MaxSet); + _customLayoutCache[key] = layout; + return layout; + } + /// インスタンシングテクスチャ描画用のパイプラインを取得します。 public Pipeline GetTexturePipeline(PassClass pass, StencilMode stencil = StencilMode.None) => GetOrCreate(PipelineKind.Texture, pass, PrimitiveTopology.TriangleList, stencil); @@ -215,29 +219,54 @@ public Pipeline GetCustomSpritePipeline( int shaderId, PassClass pass, StencilMode stencil = StencilMode.None - ) => GetOrCreateCustom(shaderId, CustomKind.Sprite, pass, stencil); + ) => GetOrCreateCustom(shaderId, CustomKind.Sprite, pass, stencil, PrimitiveTopology.TriangleList); /// カスタムシェーダーによるフルスクリーンブリット用のパイプラインを取得します。 public Pipeline GetCustomBlitPipeline(int shaderId, PassClass pass) => - GetOrCreateCustom(shaderId, CustomKind.Blit, pass, StencilMode.None); + GetOrCreateCustom(shaderId, CustomKind.Blit, pass, StencilMode.None, PrimitiveTopology.TriangleList); + + /// カスタムシェーダーによる扇形テクスチャ描画用のパイプラインを取得します。 + public Pipeline GetCustomPiePipeline( + int shaderId, + PassClass pass, + StencilMode stencil = StencilMode.None + ) => GetOrCreateCustom(shaderId, CustomKind.Pie, pass, stencil, PrimitiveTopology.TriangleList); + + /// カスタムシェーダーによるプリミティブ描画用のパイプラインを取得します。 + public Pipeline GetCustomPrimitivePipeline( + int shaderId, + PassClass pass, + PrimitiveTopology topology, + StencilMode stencil = StencilMode.None + ) => GetOrCreateCustom(shaderId, CustomKind.Primitive, pass, stencil, topology); /// /// 破棄されたカスタムシェーダーのパイプラインをキャッシュから除去します。 /// public void InvalidateShader(int shaderId) { - var keys = new List<(int, CustomKind, PassClass, StencilMode)>(); + var vk = _ctx.Vk; + var device = _ctx.Device; + + var keys = new List<(int, CustomKind, PassClass, StencilMode, PrimitiveTopology)>(); foreach (var key in _customCache.Keys) if (key.ShaderId == shaderId) keys.Add(key); - - var vk = _ctx.Vk; - var device = _ctx.Device; foreach (var key in keys) { if (_customCache.Remove(key, out var pipeline)) _ctx.DeferDestroy(() => vk.DestroyPipeline(device, pipeline, null)); } + + var layoutKeys = new List<(int, CustomKind)>(); + foreach (var key in _customLayoutCache.Keys) + if (key.ShaderId == shaderId) + layoutKeys.Add(key); + foreach (var key in layoutKeys) + { + if (_customLayoutCache.Remove(key, out var layout)) + _ctx.DeferDestroy(() => vk.DestroyPipelineLayout(device, layout, null)); + } } public void Dispose() @@ -255,6 +284,9 @@ public void Dispose() foreach (var pipeline in _customCache.Values) vk.DestroyPipeline(device, pipeline, null); _customCache.Clear(); + foreach (var layout in _customLayoutCache.Values) + vk.DestroyPipelineLayout(device, layout, null); + _customLayoutCache.Clear(); if (_initialized) { @@ -262,10 +294,9 @@ public void Dispose() vk.DestroyPipelineLayout(device, _primitiveLayout, null); vk.DestroyPipelineLayout(device, _blitLayout, null); vk.DestroyPipelineLayout(device, _pieLayout, null); - vk.DestroyPipelineLayout(device, _customSpriteLayout, null); - vk.DestroyPipelineLayout(device, _customBlitLayout, null); vk.DestroyPipelineLayout(device, _maskedLayout, null); vk.DestroyPipelineLayout(device, _stencilWriteLayout, null); + vk.DestroyDescriptorSetLayout(device, _emptySetLayout, null); } _compiler.Dispose(); @@ -279,8 +310,6 @@ private void EnsureInitialized() var vk = _ctx.Vk; var device = _ctx.Device; var textureSetLayout = _resources.TextureSetLayout; - var uboSetLayout = _materials.UboSetLayout; - var textureAndUboLayouts = stackalloc DescriptorSetLayout[2] { textureSetLayout, uboSetLayout }; // texture: set0 = sampler, push constant = mat4 (vertex) { @@ -337,29 +366,14 @@ private void EnsureInitialized() vk.CreatePipelineLayout(device, in layoutInfo, null, out _pieLayout); } - // custom sprite: set0 = sampler, set1 = UBO, push constant = mat4 (vertex) - { - var pushConstant = new PushConstantRange(ShaderStageFlags.VertexBit, 0, 64); - var layoutInfo = new PipelineLayoutCreateInfo - { - SType = StructureType.PipelineLayoutCreateInfo, - SetLayoutCount = 2, - PSetLayouts = textureAndUboLayouts, - PushConstantRangeCount = 1, - PPushConstantRanges = &pushConstant, - }; - vk.CreatePipelineLayout(device, in layoutInfo, null, out _customSpriteLayout); - } - - // custom blit: set0 = sampler, set1 = UBO + // カスタムプリミティブ用の空セットレイアウト (set0 プレースホルダー) { - var layoutInfo = new PipelineLayoutCreateInfo + var layoutInfo = new DescriptorSetLayoutCreateInfo { - SType = StructureType.PipelineLayoutCreateInfo, - SetLayoutCount = 2, - PSetLayouts = textureAndUboLayouts, + SType = StructureType.DescriptorSetLayoutCreateInfo, + BindingCount = 0, }; - vk.CreatePipelineLayout(device, in layoutInfo, null, out _customBlitLayout); + vk.CreateDescriptorSetLayout(device, in layoutInfo, null, out _emptySetLayout); } // masked: set0 = content, set1 = mask, push constant = mat4 + vec4 (両ステージ, 80 bytes) @@ -486,43 +500,83 @@ private Pipeline GetOrCreateCustom( int shaderId, CustomKind kind, PassClass pass, - StencilMode stencil + StencilMode stencil, + PrimitiveTopology topology ) { - var key = (shaderId, kind, pass, stencil); + var key = (shaderId, kind, pass, stencil, topology); if (_customCache.TryGetValue(key, out var cached)) return cached; EnsureInitialized(); var entry = _shaders.Get(shaderId); - var pipeline = kind switch + var layout = GetCustomLayout(shaderId, kind); + var (vertexLayout, enableBlend) = kind switch { - CustomKind.Sprite => CreatePipeline( - entry.VertexModule, - entry.FragmentModule, - _customSpriteLayout, - pass, - PrimitiveTopology.TriangleList, - enableBlend: true, - VertexLayout.InstancedSprite, - stencil - ), - CustomKind.Blit => CreatePipeline( - entry.VertexModule, - entry.FragmentModule, - _customBlitLayout, - pass, - PrimitiveTopology.TriangleList, - enableBlend: false, - VertexLayout.None, - stencil - ), + CustomKind.Sprite => (VertexLayout.InstancedSprite, true), + CustomKind.Blit => (VertexLayout.None, false), + CustomKind.Pie => (VertexLayout.PositionUv, true), + CustomKind.Primitive => (VertexLayout.Position2D, true), _ => throw new ArgumentOutOfRangeException(nameof(kind)), }; + + var pipeline = CreatePipeline( + entry.VertexModule, + entry.FragmentModule, + layout, + pass, + topology, + enableBlend, + vertexLayout, + stencil + ); _customCache[key] = pipeline; return pipeline; } + /// + /// カスタムシェーダー用のパイプラインレイアウトを生成します。 + /// set0 = メインテクスチャ (Primitive は空)、set1 = Uniform ブロック、 + /// set2 以降 = Material の追加テクスチャ。 + /// + private PipelineLayout CreateCustomLayout(CustomKind kind, uint maxSet) + { + var vk = _ctx.Vk; + var device = _ctx.Device; + var textureSetLayout = _resources.TextureSetLayout; + var uboSetLayout = _materials.UboSetLayout; + + var setCount = Math.Max(2u, maxSet + 1); + var setLayouts = stackalloc DescriptorSetLayout[(int)setCount]; + setLayouts[0] = kind == CustomKind.Primitive ? _emptySetLayout : textureSetLayout; + setLayouts[1] = uboSetLayout; + for (var i = 2u; i < setCount; i++) + setLayouts[i] = textureSetLayout; + + var pushConstant = kind switch + { + CustomKind.Sprite => new PushConstantRange(ShaderStageFlags.VertexBit, 0, 64), + CustomKind.Pie => new PushConstantRange( + ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit, + 0, + 96 + ), + CustomKind.Primitive => new PushConstantRange(ShaderStageFlags.FragmentBit, 0, 16), + _ => default, + }; + + var layoutInfo = new PipelineLayoutCreateInfo + { + SType = StructureType.PipelineLayoutCreateInfo, + SetLayoutCount = setCount, + PSetLayouts = setLayouts, + PushConstantRangeCount = kind == CustomKind.Blit ? 0u : 1u, + PPushConstantRanges = &pushConstant, + }; + vk.CreatePipelineLayout(device, in layoutInfo, null, out var layout); + return layout; + } + private RenderPass GetRenderPass(PassClass pass) => pass == PassClass.Offscreen ? _ctx.OffscreenClearPass : _ctx.SwapchainPass; diff --git a/Promete/Graphics/Rendering/Vulkan/VulkanScreenBlitter.cs b/Promete/Graphics/Rendering/Vulkan/VulkanScreenBlitter.cs index 3143c02..68a65cb 100644 --- a/Promete/Graphics/Rendering/Vulkan/VulkanScreenBlitter.cs +++ b/Promete/Graphics/Rendering/Vulkan/VulkanScreenBlitter.cs @@ -97,9 +97,13 @@ public unsafe void BlitToScreen(IReadOnlyList materials) material.Shader.Handle, VulkanPipelineProvider.PassClass.Offscreen ); + var layout = _pipelines.GetCustomLayout( + material.Shader.Handle, + VulkanPipelineProvider.CustomKind.Blit + ); vk.CmdBindPipeline(cmd, PipelineBindPoint.Graphics, pipeline); - BindSourceTexture(cmd, src, _pipelines.CustomBlitLayout); - _materials.Apply(cmd, material, _pipelines.CustomBlitLayout); + BindSourceTexture(cmd, src, layout); + _materials.Apply(cmd, material, layout); vk.CmdDraw(cmd, 3, 1, 0, 0); } diff --git a/Promete/Graphics/Rendering/Vulkan/VulkanShaderManager.cs b/Promete/Graphics/Rendering/Vulkan/VulkanShaderManager.cs index 7163904..62d81f4 100644 --- a/Promete/Graphics/Rendering/Vulkan/VulkanShaderManager.cs +++ b/Promete/Graphics/Rendering/Vulkan/VulkanShaderManager.cs @@ -26,10 +26,12 @@ public int Compile(string vertexSource, string fragmentSource, string name) var vertSpv = _compiler.Compile(vertexSource, ShaderKind.VertexShader, $"{name}.vert"); var fragSpv = _compiler.Compile(fragmentSource, ShaderKind.FragmentShader, $"{name}.frag"); + var (vertBlocks, vertSamplers) = SpirvReflector.Reflect(vertSpv); + var (fragBlocks, fragSamplers) = SpirvReflector.Reflect(fragSpv); + // 両ステージの Uniform ブロックを統合 (規約: set=1, binding=0) - var blocks = SpirvReflector - .ReflectUniformBlocks(vertSpv) - .Concat(SpirvReflector.ReflectUniformBlocks(fragSpv)) + var blocks = vertBlocks + .Concat(fragBlocks) .Where(b => b is { Set: 1, Binding: 0 }) .ToList(); @@ -54,9 +56,7 @@ public int Compile(string vertexSource, string fragmentSource, string name) }; } - var unsupported = SpirvReflector - .ReflectUniformBlocks(fragSpv) - .FirstOrDefault(b => b is not { Set: 1, Binding: 0 }); + var unsupported = fragBlocks.FirstOrDefault(b => b is not { Set: 1, Binding: 0 }); if (unsupported is not null) { LogHelper.Bug( @@ -64,12 +64,20 @@ public int Compile(string vertexSource, string fragmentSource, string name) ); } + // 両ステージのサンプラーを統合 (set/binding で重複排除) + var samplers = vertSamplers + .Concat(fragSamplers) + .GroupBy(s => (s.Set, s.Binding)) + .Select(g => g.First()) + .ToList(); + var id = _nextId++; _shaders[id] = new VulkanShaderEntry { VertexModule = CreateModule(vertSpv), FragmentModule = CreateModule(fragSpv), UniformBlock = merged, + Samplers = samplers, }; return id; } @@ -147,5 +155,20 @@ public sealed class VulkanShaderEntry /// set=1, binding=0 の Uniform ブロック。存在しない場合 null。 public SpirvReflector.UniformBlock? UniformBlock { get; init; } + + /// シェーダーが宣言するサンプラー変数の一覧。 + public required List Samplers { get; init; } + + /// 追加テクスチャ (set >= 2) を含めた最大セット番号。 + public uint MaxSet + { + get + { + var max = 1u; + foreach (var sampler in Samplers) + max = Math.Max(max, sampler.Set); + return max; + } + } } } diff --git a/VULKAN_PORTING_PLAN.md b/VULKAN_PORTING_PLAN.md index acf77d5..286d800 100644 --- a/VULKAN_PORTING_PLAN.md +++ b/VULKAN_PORTING_PLAN.md @@ -93,7 +93,8 @@ OpenGL 依存コードは以下の 16 ファイル・約 2,600 行に限定さ 実装済み: `VulkanDrawTextureBatchedCommandRunner` (インスタンシング + per-frame アリーナ + カスタムマテリアル対応)、`VulkanDrawPrimitiveCommandRunner`、`VulkanBeginTrim/EndTrimCommandRunner`、`VulkanDrawPieTextureCommandRunner` (push constant で MVP/tint/角度)、`VulkanScreenBlitter` (ピンポンポストプロセス + 最終ブリット)。スクリーンショット (`TakeScreenshot`/`SaveScreenshotAsync`) はポストプロセス適用後の最終ブリット元を読み出す。Promete.Experimental.Vulkan によるピクセル単位の自動検証 13 項目(スプライト/プリミティブ/ティント/FrameBuffer/PieSprite/カスタムマテリアル/色反転ポストプロセス)がパスしている。 マスク系も実装済み: オフスクリーンパス/RT にステンシルアタッチメント (D24S8/D32S8 をフォーマット照会で選択) を追加し、ステンシルマスクはパイプラインバリアント (None/WriteMask/TestEqual) + `vkCmdClearAttachments` で実現。アルファマスクは `VulkanMaskedContainerHelper` によるサブレンダリング + 2テクスチャ合成 (set0=content, set1=mask として既存のテクスチャ毎ディスクリプタセットを流用)。 -未実装 (小物): PieSprite のカスタムマテリアル、Material の Texture2D Uniform、線幅 >1 の線 (wideLines)、ImGui。 +GL 機能パリティも完了: PieSprite / Shape (Primitive) のカスタムマテリアル対応、Material の Texture2D Uniform (規約: set=2 以降に同名 sampler2D を宣言、リフレクションで名前解決)。カスタムパイプラインレイアウトはシェーダー毎に追加テクスチャ数を反映して生成・キャッシュ。 +スコープ外と決定: 線幅 >1 (wideLines 非依存の線分クワッド展開は将来検討)、ImGui の Vulkan 対応 (プラグイン側の課題)。 元の計画(規模目安: 2,000 行): From bbce355364d8e7543560ccbf9c4a30701c46cb80 Mon Sep 17 00:00:00 2001 From: Ebise Lutica <7106976+EbiseLutica@users.noreply.github.com> Date: Fri, 24 Jul 2026 02:58:02 +0900 Subject: [PATCH 12/16] =?UTF-8?q?feat(ImGui):=20Vulkan=E3=83=90=E3=83=83?= =?UTF-8?q?=E3=82=AF=E3=82=A8=E3=83=B3=E3=83=89=E3=81=A7=E3=81=AEImGui?= =?UTF-8?q?=E6=8F=8F=E7=94=BB=E3=81=AB=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VulkanImGuiController: Vulkan用のImGuiレンダラと入力処理を実装 - フォントアトラスは VulkanResourceManager に登録し、ImGui の TexID として Promete のテクスチャ ID をそのまま使用 (ImGui.Image に Texture2D.Handle が渡せる) - frames-in-flight 対応の頂点/インデックスバッファ、シザー・push constant射影 - Silk.NET.Input からのキーボード/マウス/ホイール入力を ImGui IO へ変換 - スワップチェーンパスを EndFrame まで開放し、PostRender でオーバーレイを 直接記録できるように変更 (GLの描画順と同一セマンティクス) - ImGuiPlugin: バックエンドで GL/Vulkan のコントローラを自動選択 - InternalsVisibleTo Promete.ImGui を追加 - Example: Vulkan時のImGuiPlugin無効化を解除 - Experimental: ImGuiデモウィンドウをオーバーレイした状態で20項目検証全パス Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016A9ZxvQBn4vAu8Erah3rHr --- Promete.Example/Program.cs | 7 +- Promete.Experimental.Vulkan/MainScene.cs | 12 +- Promete.Experimental.Vulkan/Program.cs | 4 +- .../Promete.Experimental.Vulkan.csproj | 1 + Promete.ImGui/IImGuiController.cs | 26 + Promete.ImGui/ImGuiPlugin.cs | 35 +- Promete.ImGui/VulkanImGuiController.cs | 670 ++++++++++++++++++ .../Backends/Vulkan/VulkanDesktopBackend.cs | 1 + .../Backends/Vulkan/VulkanDesktopGameView.cs | 9 + .../Rendering/Vulkan/VulkanContext.cs | 5 +- .../Rendering/Vulkan/VulkanScreenBlitter.cs | 4 +- Promete/Promete.csproj | 1 + 12 files changed, 751 insertions(+), 24 deletions(-) create mode 100644 Promete.ImGui/IImGuiController.cs create mode 100644 Promete.ImGui/VulkanImGuiController.cs diff --git a/Promete.Example/Program.cs b/Promete.Example/Program.cs index 4853fab..0332363 100644 --- a/Promete.Example/Program.cs +++ b/Promete.Example/Program.cs @@ -8,7 +8,6 @@ using Promete.Windowing; // --vulkan フラグで実験的な Vulkan バックエンドを使用する -// (ImGui プラグインは OpenGL 専用のため Vulkan 時は無効化) var useVulkan = args.Contains("--vulkan"); var builder = PrometeApp @@ -17,10 +16,8 @@ .Use() .Use() .Use() - .Use(); - -if (!useVulkan) - builder = builder.Use(); + .Use() + .Use(); var options = WindowOptions.Default with { diff --git a/Promete.Experimental.Vulkan/MainScene.cs b/Promete.Experimental.Vulkan/MainScene.cs index addcdee..639f720 100644 --- a/Promete.Experimental.Vulkan/MainScene.cs +++ b/Promete.Experimental.Vulkan/MainScene.cs @@ -1,6 +1,7 @@ using System.Drawing; using System.Numerics; using Promete.Graphics; +using Promete.ImGui; using Promete.Nodes; using SixLabors.ImageSharp.PixelFormats; using Color = System.Drawing.Color; @@ -14,7 +15,7 @@ namespace Promete.Experimental.Vulkan; /// フェーズ1: スプライト・プリミティブ・FrameBuffer・PieSprite・カスタムマテリアルを検証。 /// フェーズ2: ポストプロセス (色反転) を適用して検証し、終了します。 /// -public class MainScene : Scene +public class MainScene(ImGuiPlugin imGui) : Scene { private const int Phase1Frame = 60; private const int Phase2Frame = 120; @@ -258,6 +259,9 @@ public override void OnStart() .Fragment(InvertFragmentShader) .Compile(); + // ImGui 描画検証: デモウィンドウを表示 (スワップチェーンへのオーバーレイ描画パスを通す) + imGui.Render += OnImGuiRender; + Console.WriteLine("[MainScene] OnStart: ノード配置・シェーダーコンパイル完了"); } @@ -280,6 +284,7 @@ public override void OnUpdate() public override void OnDestroy() { + imGui.Render -= OnImGuiRender; _frameBuffer?.Dispose(); _overrideShader?.Dispose(); _invertShader?.Dispose(); @@ -354,6 +359,11 @@ private async Task RunPhase2Async() } } + private static void OnImGuiRender() + { + ImGuiNET.ImGui.ShowDemoWindow(); + } + /// /// 左半分が白、右半分が黒のマスクテクスチャを生成します。 /// diff --git a/Promete.Experimental.Vulkan/Program.cs b/Promete.Experimental.Vulkan/Program.cs index 0f8f9db..9436e52 100644 --- a/Promete.Experimental.Vulkan/Program.cs +++ b/Promete.Experimental.Vulkan/Program.cs @@ -1,12 +1,14 @@ using Promete; using Promete.Experimental.Vulkan; +using Promete.ImGui; using Promete.VulkanDesktop; using Promete.Windowing; // Vulkan バックエンドの起動確認用プログラム。 -// 数秒間クリアカラーを表示し、自動終了する。例外はそのままコンソールに出る。 +// 描画各機能をピクセル検証し、自動終了する。例外はそのままコンソールに出る。 var app = PrometeApp .Create() + .Use() .BuildWithVulkanDesktop( WindowOptions.Default with { diff --git a/Promete.Experimental.Vulkan/Promete.Experimental.Vulkan.csproj b/Promete.Experimental.Vulkan/Promete.Experimental.Vulkan.csproj index 2722bd8..f72290e 100644 --- a/Promete.Experimental.Vulkan/Promete.Experimental.Vulkan.csproj +++ b/Promete.Experimental.Vulkan/Promete.Experimental.Vulkan.csproj @@ -10,6 +10,7 @@ + diff --git a/Promete.ImGui/IImGuiController.cs b/Promete.ImGui/IImGuiController.cs new file mode 100644 index 0000000..61d3170 --- /dev/null +++ b/Promete.ImGui/IImGuiController.cs @@ -0,0 +1,26 @@ +namespace Promete.ImGui; + +/// +/// バックエンドごとの ImGui コントローラの共通インターフェースです。 +/// +internal interface IImGuiController : IDisposable +{ + /// フレームの状態を更新し、ImGui の新しいフレームを開始します。 + public void Update(float deltaTime); + + /// ImGui の描画データをレンダリングします。 + public void Render(); +} + +/// +/// Silk.NET の OpenGL 用 ImGuiController のアダプターです。 +/// +internal sealed class OpenGLImGuiController(Silk.NET.OpenGL.Extensions.ImGui.ImGuiController controller) + : IImGuiController +{ + public void Update(float deltaTime) => controller.Update(deltaTime); + + public void Render() => controller.Render(); + + public void Dispose() => controller.Dispose(); +} diff --git a/Promete.ImGui/ImGuiPlugin.cs b/Promete.ImGui/ImGuiPlugin.cs index ab26314..f27c4f6 100644 --- a/Promete.ImGui/ImGuiPlugin.cs +++ b/Promete.ImGui/ImGuiPlugin.cs @@ -1,17 +1,17 @@ using ImGuiNET; using Promete.Backends.GL; using Promete.Backends.SilkNetCommon; -using Silk.NET.OpenGL.Extensions.ImGui; +using Promete.Backends.Vulkan; namespace Promete.ImGui; /// /// ImGUI との連携を提供する Promete プラグインです。起動時のカスタマイズが必要な場合は、継承し、OnConfigureメソッドをオーバーライドしてください。 -/// 本プラグインは、Prometeが OpenGL デスクトップバックエンドである場合にのみ使用できます。 +/// 本プラグインは、Prometeが OpenGL または Vulkan のデスクトップバックエンドである場合に使用できます。 /// public class ImGuiPlugin(PrometeApp app, InputProvider provider) : IInitializable { - private ImGuiController? _controller; + private IImGuiController? _controller; public event Action? Render; @@ -22,16 +22,25 @@ public class ImGuiPlugin(PrometeApp app, InputProvider provider) : IInitializabl public void OnStart() { - // PrometeがOpenGLバックエンドでなければ例外をスローする - if (app.View is not OpenGLDesktopGameView glView) - throw new NotSupportedException("Promete.ImGui only supports OpenGL backend."); - - _controller = new ImGuiController( - glView.GL, - glView.NativeWindow, - provider.CreateInput(), - OnConfigure - ); + _controller = app.View switch + { + OpenGLDesktopGameView glView => new OpenGLImGuiController( + new Silk.NET.OpenGL.Extensions.ImGui.ImGuiController( + glView.GL, + glView.NativeWindow, + provider.CreateInput(), + OnConfigure + ) + ), + VulkanDesktopGameView vkView => new VulkanImGuiController( + vkView, + provider.CreateInput(), + OnConfigure + ), + _ => throw new NotSupportedException( + "Promete.ImGui only supports OpenGL and Vulkan desktop backends." + ), + }; app.Destroy += OnWindowDestroy; app.PostRender += OnWindowRender; diff --git a/Promete.ImGui/VulkanImGuiController.cs b/Promete.ImGui/VulkanImGuiController.cs new file mode 100644 index 0000000..d0f6feb --- /dev/null +++ b/Promete.ImGui/VulkanImGuiController.cs @@ -0,0 +1,670 @@ +using System.Numerics; +using System.Runtime.CompilerServices; +using ImGuiNET; +using Promete.Backends.Vulkan; +using Promete.Graphics.Rendering.Vulkan; +using Silk.NET.Input; +using Silk.NET.Shaderc; +using Silk.NET.Vulkan; +using VkBuffer = Silk.NET.Vulkan.Buffer; + +namespace Promete.ImGui; + +/// +/// Vulkan バックエンド用の ImGui コントローラです。 +/// 入力処理と、スワップチェーンパスへの ImGui 描画データのレンダリングを行います。 +/// +internal sealed unsafe class VulkanImGuiController : IImGuiController +{ + private const string VertexShaderSource = """ + #version 450 + layout(location = 0) in vec2 aPos; + layout(location = 1) in vec2 aUV; + layout(location = 2) in vec4 aColor; + + layout(push_constant) uniform PushConstants + { + vec2 uScale; + vec2 uTranslate; + }; + + layout(location = 0) out vec2 fUv; + layout(location = 1) out vec4 fColor; + + void main() + { + fUv = aUV; + fColor = aColor; + gl_Position = vec4(aPos * uScale + uTranslate, 0.0, 1.0); + } + """; + + private const string FragmentShaderSource = """ + #version 450 + layout(location = 0) in vec2 fUv; + layout(location = 1) in vec4 fColor; + + layout(set = 0, binding = 0) uniform sampler2D sTexture; + + layout(location = 0) out vec4 FragColor; + + void main() + { + FragColor = fColor * texture(sTexture, fUv); + } + """; + + private readonly VulkanDesktopGameView _view; + private readonly VulkanContext _ctx; + private readonly VulkanResourceManager _resources; + private readonly IInputContext _input; + private readonly IKeyboard? _keyboard; + private readonly IMouse? _mouse; + private readonly nint _imguiContext; + private readonly FrameBuffers[] _frames; + + private PipelineLayout _pipelineLayout; + private Pipeline _pipeline; + private int _fontTextureId; + private bool _disposed; + + public VulkanImGuiController(VulkanDesktopGameView view, IInputContext input, Action onConfigure) + { + _view = view; + _ctx = + view.RenderingContext + ?? throw new InvalidOperationException("Vulkan コンテキストが初期化されていません。"); + _resources = + view.RenderingResources + ?? throw new InvalidOperationException("リソースマネージャが初期化されていません。"); + _input = input; + _keyboard = input.Keyboards.Count > 0 ? input.Keyboards[0] : null; + _mouse = input.Mice.Count > 0 ? input.Mice[0] : null; + + _imguiContext = ImGuiNET.ImGui.CreateContext(); + ImGuiNET.ImGui.SetCurrentContext(_imguiContext); + var io = ImGuiNET.ImGui.GetIO(); + io.BackendFlags |= ImGuiBackendFlags.RendererHasVtxOffset; + io.Fonts.AddFontDefault(); + + onConfigure(); + + CreateFontTexture(io); + CreatePipeline(); + + _frames = new FrameBuffers[VulkanContext.FramesInFlight]; + for (var i = 0; i < _frames.Length; i++) + _frames[i] = new FrameBuffers(); + + SubscribeInput(); + } + + public void Update(float deltaTime) + { + ImGuiNET.ImGui.SetCurrentContext(_imguiContext); + var io = ImGuiNET.ImGui.GetIO(); + + var window = _view.NativeWindow; + var size = window.Size; + var framebuffer = window.FramebufferSize; + io.DisplaySize = new Vector2(size.X, size.Y); + if (size.X > 0 && size.Y > 0) + io.DisplayFramebufferScale = new Vector2( + framebuffer.X / (float)size.X, + framebuffer.Y / (float)size.Y + ); + + io.DeltaTime = deltaTime > 0 ? deltaTime : 1f / 60f; + + if (_mouse is not null) + { + io.MousePos = _mouse.Position; + io.MouseDown[0] = _mouse.IsButtonPressed(MouseButton.Left); + io.MouseDown[1] = _mouse.IsButtonPressed(MouseButton.Right); + io.MouseDown[2] = _mouse.IsButtonPressed(MouseButton.Middle); + } + + ImGuiNET.ImGui.NewFrame(); + } + + public void Render() + { + ImGuiNET.ImGui.SetCurrentContext(_imguiContext); + ImGuiNET.ImGui.Render(); + + if (!_ctx.IsFrameActive) + return; + + RenderDrawData(ImGuiNET.ImGui.GetDrawData()); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + + _ctx.WaitIdle(); + + var vk = _ctx.Vk; + var device = _ctx.Device; + foreach (var frame in _frames) + frame.Dispose(_ctx); + vk.DestroyPipeline(device, _pipeline, null); + vk.DestroyPipelineLayout(device, _pipelineLayout, null); + _resources.Destroy(_fontTextureId); + + ImGuiNET.ImGui.DestroyContext(_imguiContext); + } + + // --- 初期化 --- + private void CreateFontTexture(ImGuiIOPtr io) + { + io.Fonts.GetTexDataAsRGBA32(out IntPtr pixels, out var width, out var height, out _); + var span = new ReadOnlySpan((void*)pixels, width * height * 4); + _fontTextureId = _resources.CreateTexture(span, (uint)width, (uint)height); + io.Fonts.SetTexID(_fontTextureId); + io.Fonts.ClearTexData(); + } + + private void CreatePipeline() + { + var vk = _ctx.Vk; + var device = _ctx.Device; + + // レイアウト: set0 = combined sampler, push constant = vec2 scale + vec2 translate + var setLayout = _resources.TextureSetLayout; + var pushConstant = new PushConstantRange(ShaderStageFlags.VertexBit, 0, 16); + var layoutInfo = new PipelineLayoutCreateInfo + { + SType = StructureType.PipelineLayoutCreateInfo, + SetLayoutCount = 1, + PSetLayouts = &setLayout, + PushConstantRangeCount = 1, + PPushConstantRanges = &pushConstant, + }; + vk.CreatePipelineLayout(device, in layoutInfo, null, out _pipelineLayout); + + // シェーダー + using var compiler = new VulkanShaderCompiler(); + var vertSpv = compiler.Compile(VertexShaderSource, ShaderKind.VertexShader, "imgui.vert"); + var fragSpv = compiler.Compile(FragmentShaderSource, ShaderKind.FragmentShader, "imgui.frag"); + var vertModule = CreateShaderModule(vertSpv); + var fragModule = CreateShaderModule(fragSpv); + + var entryPoint = (byte*)Silk.NET.Core.Native.SilkMarshal.StringToPtr("main"); + var stages = stackalloc PipelineShaderStageCreateInfo[2] + { + new PipelineShaderStageCreateInfo + { + SType = StructureType.PipelineShaderStageCreateInfo, + Stage = ShaderStageFlags.VertexBit, + Module = vertModule, + PName = entryPoint, + }, + new PipelineShaderStageCreateInfo + { + SType = StructureType.PipelineShaderStageCreateInfo, + Stage = ShaderStageFlags.FragmentBit, + Module = fragModule, + PName = entryPoint, + }, + }; + + // 頂点レイアウト: ImDrawVert (pos2 + uv2 + col u8x4) + var binding = new VertexInputBindingDescription(0, 20, VertexInputRate.Vertex); + var attributes = stackalloc VertexInputAttributeDescription[3] + { + new VertexInputAttributeDescription(0, 0, Format.R32G32Sfloat, 0), + new VertexInputAttributeDescription(1, 0, Format.R32G32Sfloat, 8), + new VertexInputAttributeDescription(2, 0, Format.R8G8B8A8Unorm, 16), + }; + var vertexInput = new PipelineVertexInputStateCreateInfo + { + SType = StructureType.PipelineVertexInputStateCreateInfo, + VertexBindingDescriptionCount = 1, + PVertexBindingDescriptions = &binding, + VertexAttributeDescriptionCount = 3, + PVertexAttributeDescriptions = attributes, + }; + + var inputAssembly = new PipelineInputAssemblyStateCreateInfo + { + SType = StructureType.PipelineInputAssemblyStateCreateInfo, + Topology = PrimitiveTopology.TriangleList, + }; + + var viewportState = new PipelineViewportStateCreateInfo + { + SType = StructureType.PipelineViewportStateCreateInfo, + ViewportCount = 1, + ScissorCount = 1, + }; + + var rasterization = new PipelineRasterizationStateCreateInfo + { + SType = StructureType.PipelineRasterizationStateCreateInfo, + PolygonMode = PolygonMode.Fill, + CullMode = CullModeFlags.None, + FrontFace = FrontFace.Clockwise, + LineWidth = 1f, + }; + + var multisample = new PipelineMultisampleStateCreateInfo + { + SType = StructureType.PipelineMultisampleStateCreateInfo, + RasterizationSamples = SampleCountFlags.Count1Bit, + }; + + var blendAttachment = new PipelineColorBlendAttachmentState + { + BlendEnable = true, + SrcColorBlendFactor = BlendFactor.SrcAlpha, + DstColorBlendFactor = BlendFactor.OneMinusSrcAlpha, + ColorBlendOp = BlendOp.Add, + SrcAlphaBlendFactor = BlendFactor.One, + DstAlphaBlendFactor = BlendFactor.OneMinusSrcAlpha, + AlphaBlendOp = BlendOp.Add, + ColorWriteMask = + ColorComponentFlags.RBit + | ColorComponentFlags.GBit + | ColorComponentFlags.BBit + | ColorComponentFlags.ABit, + }; + var colorBlend = new PipelineColorBlendStateCreateInfo + { + SType = StructureType.PipelineColorBlendStateCreateInfo, + AttachmentCount = 1, + PAttachments = &blendAttachment, + }; + + var dynamicStates = stackalloc DynamicState[2] { DynamicState.Viewport, DynamicState.Scissor }; + var dynamicState = new PipelineDynamicStateCreateInfo + { + SType = StructureType.PipelineDynamicStateCreateInfo, + DynamicStateCount = 2, + PDynamicStates = dynamicStates, + }; + + var createInfo = new GraphicsPipelineCreateInfo + { + SType = StructureType.GraphicsPipelineCreateInfo, + StageCount = 2, + PStages = stages, + PVertexInputState = &vertexInput, + PInputAssemblyState = &inputAssembly, + PViewportState = &viewportState, + PRasterizationState = &rasterization, + PMultisampleState = &multisample, + PColorBlendState = &colorBlend, + PDynamicState = &dynamicState, + Layout = _pipelineLayout, + RenderPass = _ctx.SwapchainPass, + Subpass = 0, + }; + + var result = vk.CreateGraphicsPipelines(device, default, 1, in createInfo, null, out _pipeline); + + Silk.NET.Core.Native.SilkMarshal.Free((nint)entryPoint); + vk.DestroyShaderModule(device, vertModule, null); + vk.DestroyShaderModule(device, fragModule, null); + + if (result != Result.Success) + throw new InvalidOperationException($"ImGui パイプラインの作成に失敗しました: {result}"); + } + + private ShaderModule CreateShaderModule(byte[] spirv) + { + fixed (byte* code = spirv) + { + var createInfo = new ShaderModuleCreateInfo + { + SType = StructureType.ShaderModuleCreateInfo, + CodeSize = (nuint)spirv.Length, + PCode = (uint*)code, + }; + var result = _ctx.Vk.CreateShaderModule(_ctx.Device, in createInfo, null, out var module); + if (result != Result.Success) + throw new InvalidOperationException($"シェーダーモジュールの作成に失敗しました: {result}"); + return module; + } + } + + // --- 入力 --- + private void SubscribeInput() + { + if (_keyboard is not null) + { + _keyboard.KeyDown += OnKeyDown; + _keyboard.KeyUp += OnKeyUp; + _keyboard.KeyChar += OnKeyChar; + } + + if (_mouse is not null) + _mouse.Scroll += OnScroll; + } + + private void OnKeyDown(IKeyboard keyboard, Key key, int scancode) => OnKey(key, true); + + private void OnKeyUp(IKeyboard keyboard, Key key, int scancode) => OnKey(key, false); + + private void OnKey(Key key, bool down) + { + ImGuiNET.ImGui.SetCurrentContext(_imguiContext); + var io = ImGuiNET.ImGui.GetIO(); + + var imguiKey = TranslateKey(key); + if (imguiKey != ImGuiKey.None) + io.AddKeyEvent(imguiKey, down); + + // 修飾キー + switch (key) + { + case Key.ControlLeft or Key.ControlRight: + io.AddKeyEvent(ImGuiKey.ModCtrl, down); + break; + case Key.ShiftLeft or Key.ShiftRight: + io.AddKeyEvent(ImGuiKey.ModShift, down); + break; + case Key.AltLeft or Key.AltRight: + io.AddKeyEvent(ImGuiKey.ModAlt, down); + break; + case Key.SuperLeft or Key.SuperRight: + io.AddKeyEvent(ImGuiKey.ModSuper, down); + break; + } + } + + private void OnKeyChar(IKeyboard keyboard, char c) + { + ImGuiNET.ImGui.SetCurrentContext(_imguiContext); + ImGuiNET.ImGui.GetIO().AddInputCharacter(c); + } + + private void OnScroll(IMouse mouse, ScrollWheel wheel) + { + ImGuiNET.ImGui.SetCurrentContext(_imguiContext); + ImGuiNET.ImGui.GetIO().AddMouseWheelEvent(wheel.X, wheel.Y); + } + + private static ImGuiKey TranslateKey(Key key) + { + if (key is >= Key.A and <= Key.Z) + return ImGuiKey.A + (key - Key.A); + if (key is >= Key.Number0 and <= Key.Number9) + return ImGuiKey._0 + (key - Key.Number0); + if (key is >= Key.F1 and <= Key.F12) + return ImGuiKey.F1 + (key - Key.F1); + if (key is >= Key.Keypad0 and <= Key.Keypad9) + return ImGuiKey.Keypad0 + (key - Key.Keypad0); + + return key switch + { + Key.Tab => ImGuiKey.Tab, + Key.Left => ImGuiKey.LeftArrow, + Key.Right => ImGuiKey.RightArrow, + Key.Up => ImGuiKey.UpArrow, + Key.Down => ImGuiKey.DownArrow, + Key.PageUp => ImGuiKey.PageUp, + Key.PageDown => ImGuiKey.PageDown, + Key.Home => ImGuiKey.Home, + Key.End => ImGuiKey.End, + Key.Insert => ImGuiKey.Insert, + Key.Delete => ImGuiKey.Delete, + Key.Backspace => ImGuiKey.Backspace, + Key.Space => ImGuiKey.Space, + Key.Enter => ImGuiKey.Enter, + Key.Escape => ImGuiKey.Escape, + Key.Apostrophe => ImGuiKey.Apostrophe, + Key.Comma => ImGuiKey.Comma, + Key.Minus => ImGuiKey.Minus, + Key.Period => ImGuiKey.Period, + Key.Slash => ImGuiKey.Slash, + Key.Semicolon => ImGuiKey.Semicolon, + Key.Equal => ImGuiKey.Equal, + Key.LeftBracket => ImGuiKey.LeftBracket, + Key.BackSlash => ImGuiKey.Backslash, + Key.RightBracket => ImGuiKey.RightBracket, + Key.GraveAccent => ImGuiKey.GraveAccent, + Key.CapsLock => ImGuiKey.CapsLock, + Key.ScrollLock => ImGuiKey.ScrollLock, + Key.NumLock => ImGuiKey.NumLock, + Key.PrintScreen => ImGuiKey.PrintScreen, + Key.Pause => ImGuiKey.Pause, + Key.KeypadDecimal => ImGuiKey.KeypadDecimal, + Key.KeypadDivide => ImGuiKey.KeypadDivide, + Key.KeypadMultiply => ImGuiKey.KeypadMultiply, + Key.KeypadSubtract => ImGuiKey.KeypadSubtract, + Key.KeypadAdd => ImGuiKey.KeypadAdd, + Key.KeypadEnter => ImGuiKey.KeypadEnter, + Key.KeypadEqual => ImGuiKey.KeypadEqual, + Key.ControlLeft => ImGuiKey.LeftCtrl, + Key.ShiftLeft => ImGuiKey.LeftShift, + Key.AltLeft => ImGuiKey.LeftAlt, + Key.SuperLeft => ImGuiKey.LeftSuper, + Key.ControlRight => ImGuiKey.RightCtrl, + Key.ShiftRight => ImGuiKey.RightShift, + Key.AltRight => ImGuiKey.RightAlt, + Key.SuperRight => ImGuiKey.RightSuper, + Key.Menu => ImGuiKey.Menu, + _ => ImGuiKey.None, + }; + } + + // --- レンダリング --- + private void RenderDrawData(ImDrawDataPtr drawData) + { + var framebufferWidth = (int)(drawData.DisplaySize.X * drawData.FramebufferScale.X); + var framebufferHeight = (int)(drawData.DisplaySize.Y * drawData.FramebufferScale.Y); + if (framebufferWidth <= 0 || framebufferHeight <= 0 || drawData.CmdListsCount == 0) + return; + + var vk = _ctx.Vk; + var cmd = _ctx.CurrentCommandBuffer; + var frame = _frames[_ctx.FrameIndex]; + + // 頂点・インデックスデータを転送 + var vertexSize = (ulong)(drawData.TotalVtxCount * sizeof(ImDrawVert)); + var indexSize = (ulong)(drawData.TotalIdxCount * sizeof(ushort)); + if (vertexSize == 0 || indexSize == 0) + return; + + frame.EnsureCapacity(_ctx, vertexSize, indexSize); + + var vtxOffsetBytes = 0ul; + var idxOffsetBytes = 0ul; + for (var i = 0; i < drawData.CmdListsCount; i++) + { + var cmdList = new ImDrawListPtr(((ImDrawList**)drawData.NativePtr->CmdLists.Data)[i]); + var listVtxSize = (ulong)(cmdList.VtxBuffer.Size * sizeof(ImDrawVert)); + var listIdxSize = (ulong)(cmdList.IdxBuffer.Size * sizeof(ushort)); + System.Buffer.MemoryCopy( + (void*)cmdList.VtxBuffer.Data, + frame.VertexMapped + vtxOffsetBytes, + listVtxSize, + listVtxSize + ); + System.Buffer.MemoryCopy( + (void*)cmdList.IdxBuffer.Data, + frame.IndexMapped + idxOffsetBytes, + listIdxSize, + listIdxSize + ); + vtxOffsetBytes += listVtxSize; + idxOffsetBytes += listIdxSize; + } + + // パイプラインをバインドし、描画設定を行う + vk.CmdBindPipeline(cmd, PipelineBindPoint.Graphics, _pipeline); + + var offset = 0ul; + var vertexBuffer = frame.VertexBuffer; + vk.CmdBindVertexBuffers(cmd, 0, 1, in vertexBuffer, in offset); + vk.CmdBindIndexBuffer(cmd, frame.IndexBuffer, 0, IndexType.Uint16); + + var viewport = new Viewport(0, 0, framebufferWidth, framebufferHeight, 0f, 1f); + vk.CmdSetViewport(cmd, 0, 1, in viewport); + + // 射影: スケールと平行移動を push constant で渡す + var pushData = stackalloc float[4] + { + 2f / drawData.DisplaySize.X, + 2f / drawData.DisplaySize.Y, + -1f - (drawData.DisplayPos.X * (2f / drawData.DisplaySize.X)), + -1f - (drawData.DisplayPos.Y * (2f / drawData.DisplaySize.Y)), + }; + vk.CmdPushConstants(cmd, _pipelineLayout, ShaderStageFlags.VertexBit, 0, 16, pushData); + + // 描画コマンドを実行 + var globalVtxOffset = 0; + var globalIdxOffset = 0; + var clipOffset = drawData.DisplayPos; + var clipScale = drawData.FramebufferScale; + + for (var i = 0; i < drawData.CmdListsCount; i++) + { + var cmdList = new ImDrawListPtr(((ImDrawList**)drawData.NativePtr->CmdLists.Data)[i]); + for (var j = 0; j < cmdList.CmdBuffer.Size; j++) + { + var drawCmd = cmdList.CmdBuffer[j]; + + var clipMin = new Vector2( + (drawCmd.ClipRect.X - clipOffset.X) * clipScale.X, + (drawCmd.ClipRect.Y - clipOffset.Y) * clipScale.Y + ); + var clipMax = new Vector2( + (drawCmd.ClipRect.Z - clipOffset.X) * clipScale.X, + (drawCmd.ClipRect.W - clipOffset.Y) * clipScale.Y + ); + clipMin = Vector2.Max(clipMin, Vector2.Zero); + clipMax = Vector2.Min(clipMax, new Vector2(framebufferWidth, framebufferHeight)); + if (clipMax.X <= clipMin.X || clipMax.Y <= clipMin.Y) + continue; + + var scissor = new Rect2D( + new Offset2D((int)clipMin.X, (int)clipMin.Y), + new Extent2D((uint)(clipMax.X - clipMin.X), (uint)(clipMax.Y - clipMin.Y)) + ); + vk.CmdSetScissor(cmd, 0, 1, in scissor); + + // TexID は Promete のテクスチャ ID (VulkanResourceManager) + var textureId = (int)drawCmd.TextureId; + if (!_resources.Contains(textureId)) + continue; + var descriptorSet = _resources.GetDescriptorSet(textureId); + vk.CmdBindDescriptorSets( + cmd, + PipelineBindPoint.Graphics, + _pipelineLayout, + 0, + 1, + in descriptorSet, + 0, + null + ); + + vk.CmdDrawIndexed( + cmd, + drawCmd.ElemCount, + 1, + (uint)(globalIdxOffset + drawCmd.IdxOffset), + (int)(globalVtxOffset + drawCmd.VtxOffset), + 0 + ); + } + + globalIdxOffset += cmdList.IdxBuffer.Size; + globalVtxOffset += cmdList.VtxBuffer.Size; + } + } + + /// + /// フレームスロットごとの頂点・インデックスバッファです。容量不足時に再確保します。 + /// + private sealed class FrameBuffers + { + private DeviceMemory _vertexMemory; + private DeviceMemory _indexMemory; + private ulong _vertexCapacity; + private ulong _indexCapacity; + + public VkBuffer VertexBuffer { get; private set; } + + public VkBuffer IndexBuffer { get; private set; } + + public byte* VertexMapped { get; private set; } + + public byte* IndexMapped { get; private set; } + + public void EnsureCapacity(VulkanContext ctx, ulong vertexSize, ulong indexSize) + { + // このスロットのフェンスは BeginFrame で待機済みのため、旧バッファは即時破棄できる + if (_vertexCapacity < vertexSize) + { + DestroyBuffer(ctx, VertexBuffer, _vertexMemory); + _vertexCapacity = Math.Max(vertexSize, 64 * 1024); + CreateBuffer( + ctx, + _vertexCapacity, + BufferUsageFlags.VertexBufferBit, + out var buffer, + out _vertexMemory, + out var mapped + ); + VertexBuffer = buffer; + VertexMapped = mapped; + } + + if (_indexCapacity < indexSize) + { + DestroyBuffer(ctx, IndexBuffer, _indexMemory); + _indexCapacity = Math.Max(indexSize, 16 * 1024); + CreateBuffer( + ctx, + _indexCapacity, + BufferUsageFlags.IndexBufferBit, + out var buffer, + out _indexMemory, + out var mapped + ); + IndexBuffer = buffer; + IndexMapped = mapped; + } + } + + public void Dispose(VulkanContext ctx) + { + DestroyBuffer(ctx, VertexBuffer, _vertexMemory); + DestroyBuffer(ctx, IndexBuffer, _indexMemory); + VertexBuffer = default; + IndexBuffer = default; + } + + private static void CreateBuffer( + VulkanContext ctx, + ulong size, + BufferUsageFlags usage, + out VkBuffer buffer, + out DeviceMemory memory, + out byte* mapped + ) + { + (buffer, memory) = ctx.CreateBuffer( + size, + usage, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit + ); + void* p; + ctx.Vk.MapMemory(ctx.Device, memory, 0, size, 0, &p); + mapped = (byte*)p; + } + + private static void DestroyBuffer(VulkanContext ctx, VkBuffer buffer, DeviceMemory memory) + { + if (buffer.Handle == 0) + return; + ctx.Vk.DestroyBuffer(ctx.Device, buffer, null); + ctx.Vk.FreeMemory(ctx.Device, memory, null); + } + } +} diff --git a/Promete/Backends/Vulkan/VulkanDesktopBackend.cs b/Promete/Backends/Vulkan/VulkanDesktopBackend.cs index e290495..f2852e6 100644 --- a/Promete/Backends/Vulkan/VulkanDesktopBackend.cs +++ b/Promete/Backends/Vulkan/VulkanDesktopBackend.cs @@ -84,6 +84,7 @@ public override void OnInitialize(PrometeApp app, WindowOptions opts) ); _gameView.AttachRenderingResources( _context, + _resources, _renderTextureProvider, _screenBlitter, _textureFactory diff --git a/Promete/Backends/Vulkan/VulkanDesktopGameView.cs b/Promete/Backends/Vulkan/VulkanDesktopGameView.cs index d200810..3db3bba 100644 --- a/Promete/Backends/Vulkan/VulkanDesktopGameView.cs +++ b/Promete/Backends/Vulkan/VulkanDesktopGameView.cs @@ -20,6 +20,7 @@ public class VulkanDesktopGameView : IGameView { private readonly PrometeApp _app; private VulkanContext? _context; + private VulkanResourceManager? _resources; private VulkanRenderTextureProvider? _renderTextureProvider; private VulkanScreenBlitter? _screenBlitter; private TextureFactoryBase? _textureFactory; @@ -138,6 +139,12 @@ public string Title } } + /// Vulkan コンテキストを取得します。(Promete.ImGui 等の内部連携用) + internal VulkanContext? RenderingContext => _context; + + /// リソースマネージャを取得します。(Promete.ImGui 等の内部連携用) + internal VulkanResourceManager? RenderingResources => _resources; + public WindowMode Mode { get => @@ -181,12 +188,14 @@ public void UpdateWindowSize() /// internal void AttachRenderingResources( VulkanContext context, + VulkanResourceManager resources, VulkanRenderTextureProvider renderTextureProvider, VulkanScreenBlitter screenBlitter, TextureFactoryBase textureFactory ) { _context = context; + _resources = resources; _renderTextureProvider = renderTextureProvider; _screenBlitter = screenBlitter; _textureFactory = textureFactory; diff --git a/Promete/Graphics/Rendering/Vulkan/VulkanContext.cs b/Promete/Graphics/Rendering/Vulkan/VulkanContext.cs index 7762f9d..82d338e 100644 --- a/Promete/Graphics/Rendering/Vulkan/VulkanContext.cs +++ b/Promete/Graphics/Rendering/Vulkan/VulkanContext.cs @@ -222,10 +222,11 @@ public void EndFrame() // ブリットが行われなかった場合でも、スワップチェーンイメージをプレゼント可能な状態にする if (!_swapchainPassDone) - { BeginSwapchainPass(Color.Black); + + // スワップチェーンパスは ImGui 等のオーバーレイ描画のためフレーム終了まで開いたままにしている + if (_swapchainPassActive) EndSwapchainPass(); - } ThrowIfFailed(vk.EndCommandBuffer(cmd), "コマンドバッファの記録終了"); _frameActive = false; diff --git a/Promete/Graphics/Rendering/Vulkan/VulkanScreenBlitter.cs b/Promete/Graphics/Rendering/Vulkan/VulkanScreenBlitter.cs index 68a65cb..c8c0304 100644 --- a/Promete/Graphics/Rendering/Vulkan/VulkanScreenBlitter.cs +++ b/Promete/Graphics/Rendering/Vulkan/VulkanScreenBlitter.cs @@ -112,7 +112,8 @@ public unsafe void BlitToScreen(IReadOnlyList materials) } } - // 最終結果をスワップチェーンへブリット + // 最終結果をスワップチェーンへブリット。 + // パスは終了せず開いたままにする (PostRender でのオーバーレイ描画用。EndFrame が閉じる) _ctx.BeginSwapchainPass(Color.Black); { var cmd = _ctx.CurrentCommandBuffer; @@ -122,7 +123,6 @@ public unsafe void BlitToScreen(IReadOnlyList materials) vk.CmdDraw(cmd, 3, 1, 0, 0); } - _ctx.EndSwapchainPass(); LastBlitSource = src; } diff --git a/Promete/Promete.csproj b/Promete/Promete.csproj index 9b12e0d..2a898e5 100644 --- a/Promete/Promete.csproj +++ b/Promete/Promete.csproj @@ -24,6 +24,7 @@ + From 380770db38029214732fd5ae693e6674537a891b Mon Sep 17 00:00:00 2001 From: Ebise Lutica <7106976+EbiseLutica@users.noreply.github.com> Date: Fri, 24 Jul 2026 03:32:24 +0900 Subject: [PATCH 13/16] =?UTF-8?q?chore:=20=E3=83=90=E3=83=BC=E3=82=B8?= =?UTF-8?q?=E3=83=A7=E3=83=B3=E3=82=92=202.0.0-beta.8=20=E3=81=AB=E6=9B=B4?= =?UTF-8?q?=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Promete.ImGui/Promete.ImGui.csproj | 2 +- Promete/Promete.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Promete.ImGui/Promete.ImGui.csproj b/Promete.ImGui/Promete.ImGui.csproj index 6cf65b2..db9d9f3 100644 --- a/Promete.ImGui/Promete.ImGui.csproj +++ b/Promete.ImGui/Promete.ImGui.csproj @@ -17,7 +17,7 @@ Promete.ImGui - 2.0.0-beta.1 + 2.0.0-beta.8 Game Engine;2D;gamedev;games;gaming;ui;gui;imgui ImGui support for Promete diff --git a/Promete/Promete.csproj b/Promete/Promete.csproj index 2a898e5..36c7ceb 100644 --- a/Promete/Promete.csproj +++ b/Promete/Promete.csproj @@ -10,7 +10,7 @@ Promete - 2.0.0-beta.7 + 2.0.0-beta.8 Game Engine;2D;gamedev;games;gaming;windowing;OpenGL A 2D-specified, lightweight, extensible and easy-to-use game engine. From 419acfb200e5a5b0291e6fb88beda5e488c8d1a6 Mon Sep 17 00:00:00 2001 From: Ebise Lutica <7106976+EbiseLutica@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:04:26 +0900 Subject: [PATCH 14/16] =?UTF-8?q?vulkan=E3=83=86=E3=82=B9=E3=83=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Promete.Example/Kernel/DemoKernel.cs | 2 + Promete.Example/MainScene.cs | 5 +- Promete.Example/Program.cs | 7 +- .../examples/debug/TrimClampDebugScene.cs | 195 +++++++++++ .../debug/VulkanFrameArenaGrowthDebugScene.cs | 81 +++++ .../debug/VulkanMaskRestoreDebugScene.cs | 112 +++++++ .../debug/VulkanMaterialSlotLeakDebugScene.cs | 128 +++++++ .../debug/VulkanShaderReflectionDebugScene.cs | 200 +++++++++++ Promete.Test/SpirvReflectorTests.cs | 313 ++++++++++++++++++ 9 files changed, 1038 insertions(+), 5 deletions(-) create mode 100644 Promete.Example/examples/debug/TrimClampDebugScene.cs create mode 100644 Promete.Example/examples/debug/VulkanFrameArenaGrowthDebugScene.cs create mode 100644 Promete.Example/examples/debug/VulkanMaskRestoreDebugScene.cs create mode 100644 Promete.Example/examples/debug/VulkanMaterialSlotLeakDebugScene.cs create mode 100644 Promete.Example/examples/debug/VulkanShaderReflectionDebugScene.cs create mode 100644 Promete.Test/SpirvReflectorTests.cs diff --git a/Promete.Example/Kernel/DemoKernel.cs b/Promete.Example/Kernel/DemoKernel.cs index 0273c8d..896da33 100644 --- a/Promete.Example/Kernel/DemoKernel.cs +++ b/Promete.Example/Kernel/DemoKernel.cs @@ -7,4 +7,6 @@ public static class DemoKernel public static Folder CurrentFolder { get; set; } = FileSystem.Root; public static int CurrentIndex { get; set; } + + public static bool UseVulkan { get; set; } } diff --git a/Promete.Example/MainScene.cs b/Promete.Example/MainScene.cs index 01e08e3..3306cab 100644 --- a/Promete.Example/MainScene.cs +++ b/Promete.Example/MainScene.cs @@ -17,9 +17,10 @@ public override void OnUpdate() private void OutputUI() { console.Clear(); - console.Print("Promete Demo\n"); + var rendererName = DemoKernel.UseVulkan ? "Vulkan" : "OpenGL"; + console.Print($"Promete Demo ({rendererName})\n"); console.Print($"現在のディレクトリ: /{CurrentFolder.GetFullPath()}\n"); - View.Title = $"Promete Demo - {CurrentFolder.GetFullPath()}"; + View.Title = $"Promete Demo ({rendererName}) - {CurrentFolder.GetFullPath()}"; for (var i = 0; i < CurrentFolder.Files.Count; i++) { diff --git a/Promete.Example/Program.cs b/Promete.Example/Program.cs index 0332363..6a35062 100644 --- a/Promete.Example/Program.cs +++ b/Promete.Example/Program.cs @@ -1,6 +1,7 @@ using Promete; using Promete.Coroutines; using Promete.Example; +using Promete.Example.Kernel; using Promete.GLDesktop; using Promete.ImGui; using Promete.Input; @@ -8,7 +9,7 @@ using Promete.Windowing; // --vulkan フラグで実験的な Vulkan バックエンドを使用する -var useVulkan = args.Contains("--vulkan"); +DemoKernel.UseVulkan = args.Contains("--vulkan"); var builder = PrometeApp .Create() @@ -21,14 +22,14 @@ var options = WindowOptions.Default with { - Title = useVulkan ? "Promete Demo (Vulkan)" : "Promete Demo", + Title = DemoKernel.UseVulkan ? "Promete Demo (Vulkan)" : "Promete Demo", Mode = WindowMode.Resizable, TargetFps = 0, TargetUps = 0, IsVsyncMode = false, }; -var app = useVulkan +var app = DemoKernel.UseVulkan ? builder.BuildWithVulkanDesktop(options) : builder.BuildWithOpenGLDesktop(options); diff --git a/Promete.Example/examples/debug/TrimClampDebugScene.cs b/Promete.Example/examples/debug/TrimClampDebugScene.cs new file mode 100644 index 0000000..2e0530e --- /dev/null +++ b/Promete.Example/examples/debug/TrimClampDebugScene.cs @@ -0,0 +1,195 @@ +using System.Drawing; +using Promete.Example.Kernel; +using Promete.Graphics; +using Promete.Input; +using Promete.Nodes; + +namespace Promete.Example.examples.debug; + +/// +/// レビュー指摘 #03 の再現シーン。 +/// +/// 発生源は RenderCommandQueue.PushTrim (RenderCommandQueue.cs:97-100)。 +/// トリム矩形の原点が負のとき 0 にクランプするが、クランプした分を size から +/// 減算しないため、可視領域が「はみ出した量」だけ広いまま残る。 +/// GL / Vulkan 双方のランナーが同じ矩形を受け取る、両バックエンド共通の不具合。 +/// +/// 重要: トリムコンテナ単体では、この不具合は目に見えない。 +/// クリップ矩形はコンテナ自身の位置とサイズから作られるため、過大になった帯は +/// 必ずそのコンテナの中身より右側、つまり何も描かれていない領域に落ちるためである。 +/// +/// 可視化にはトリムコンテナの入れ子が要る。PushTrim は親トリムとの積集合を取る +/// (RenderCommandQueue.cs:115-123) ので、外側の過大な幅が内側の積集合に漏れる。 +/// 外側を左へはみ出させて幅を過大にし、内側は画面内に置いて中身を持たせると、 +/// 内側が本来の右端を越えて描画される。 +/// +/// 外側 loc=(-200,140) size=400x150 / 内側 loc=画面(50,140) size=300x150 のとき: +/// 正しい内側クリップ = x 50..200 (幅150) +/// 実装の内側クリップ = x 50..350 (幅300) -> 150px ぶん余分に見える +/// +[Demo("/debug/trim_clamp", "指摘#03: 入れ子トリムで内側のクリップ幅が過大になる")] +public class TrimClampDebugScene(ConsoleLayer console, Keyboard keyboard) : Scene +{ + private const int TrimTop = 140; + private const int TrimHeight = 150; + + private const int OuterWidth = 400; + private const int InnerWidth = 300; + + /// 内側コンテナの画面上の左端。外側の可視領域内に置く。 + private const int InnerScreenX = 50; + + /// 外側コンテナ。左へはみ出させてクリップ幅を過大にする。 + private readonly Container _outer = new Container().Size(OuterWidth, TrimHeight); + + /// 内側コンテナ。中身を全域に持ち、外側の過大な幅の影響を受ける。 + private readonly Container _inner = new Container().Size(InnerWidth, TrimHeight); + + private Container _guide = null!; + private int _outerX = -200; + + public override void OnStart() + { + _outer.IsTrimmable = true; + _inner.IsTrimmable = true; + + // 内側の中身。全域に敷き詰めることで、過大な帯に「切り取られるべき中身」を置く + for (var x = 0; x < InnerWidth; x += 20) + { + var isEven = x / 20 % 2 == 0; + _inner.Add( + Shape.CreateRect( + x, + 0, + x + 19, + TrimHeight - 1, + isEven ? Color.MediumSeaGreen : Color.SeaGreen + ) + ); + } + + // 外側の中身。内側との重なりを避けるため上端の薄い帯だけにする + for (var x = 0; x < OuterWidth; x += 20) + { + var isEven = x / 20 % 2 == 0; + _outer.Add( + Shape.CreateRect(x, 0, x + 19, 10, isEven ? Color.SteelBlue : Color.DarkSlateBlue) + ); + } + + _outer.Add(_inner); + Root.Add(_outer); + + _guide = new Container(); + Root.Add(_guide); + + SetOuterX(-200); + + console.Print("指摘#03 再現シーン (入れ子トリム)"); + console.Print("左右キー: 外側コンテナを移動 / R: -200 へ戻す / 0: 0 へ"); + } + + public override void OnUpdate() + { + if (keyboard.Left) + SetOuterX(_outerX - 4); + + if (keyboard.Right) + SetOuterX(_outerX + 4); + + if (keyboard.R.IsKeyDown) + SetOuterX(-200); + + if (keyboard.Number0.IsKeyDown) + SetOuterX(0); + + if (keyboard.Escape.IsKeyUp) + App.LoadScene(); + } + + /// + /// 外側の位置を更新する。内側は画面上で固定に見えるよう、外側ローカル座標を補正する。 + /// + private void SetOuterX(int outerX) + { + _outerX = Math.Clamp(outerX, -OuterWidth, 0); + + _outer.Location = (_outerX, TrimTop); + + // 内側は常に画面 x = InnerScreenX に来るようにする + _inner.Location = (InnerScreenX - _outerX, 0); + + UpdateGuide(); + PrintState(); + } + + /// + /// 内側の期待クリップ右端 (赤) と、実装が使う右端 (黄) を引く。 + /// + private void UpdateGuide() + { + var (expectedRight, actualRight) = ComputeInnerRight(); + + var top = TrimTop - 20; + var bottom = TrimTop + TrimHeight + 20; + + _guide.Clear(); + _guide.Add(Shape.CreateLine(expectedRight, top, expectedRight, bottom, Color.Red)); + + if (actualRight != expectedRight) + _guide.Add(Shape.CreateLine(actualRight, top, actualRight, bottom, Color.Yellow)); + } + + /// + /// 内側トリムの右端を、期待値と実装値の双方について求める。 + /// PushTrim と同じく、親トリムとの積集合を左右両端について取る。 + /// + private (int Expected, int Actual) ComputeInnerRight() + { + // 外側: 原点を 0 にクランプする際、実装は幅を縮めない + var outerClampedX = Math.Max(0, _outerX); + var outerExpectedWidth = _outerX < 0 ? OuterWidth + _outerX : OuterWidth; + + var actual = IntersectRight(outerClampedX, outerClampedX + OuterWidth); + var expected = IntersectRight(outerClampedX, outerClampedX + outerExpectedWidth); + + return (expected, actual); + } + + /// + /// 内側の矩形を親トリム [parentLeft, parentRight] と積集合し、その右端を返す。 + /// 幅が負にならないよう左端でクランプする。 + /// + private static int IntersectRight(int parentLeft, int parentRight) + { + var left = Math.Max(InnerScreenX, parentLeft); + var right = Math.Min(InnerScreenX + InnerWidth, parentRight); + return Math.Max(left, right); + } + + private void PrintState() + { + var (expectedRight, actualRight) = ComputeInnerRight(); + var over = actualRight - expectedRight; + + console.Clear(); + console.Print("指摘#03 再現シーン (入れ子トリム)"); + console.Print("左右キー: 外側を移動 / R: -200 / 0: 0"); + console.Print("赤線 = 内側の期待クリップ右端 / 黄線 = 実装が使う右端"); + console.Print(""); + console.Print($"外側 loc.X = {_outerX} (size {OuterWidth}x{TrimHeight})"); + console.Print($"内側 画面X = {InnerScreenX} (size {InnerWidth}x{TrimHeight})"); + console.Print($"内側クリップ右端 期待 = {expectedRight} / 実装 = {actualRight}"); + console.Print( + over > 0 + ? $"緑の帯が赤線を {over}px 越えていれば再現" + : "過大なし (外側がはみ出していないため一致)" + ); + } + + public override void OnDestroy() + { + _guide.Destroy(); + _outer.Destroy(); + } +} diff --git a/Promete.Example/examples/debug/VulkanFrameArenaGrowthDebugScene.cs b/Promete.Example/examples/debug/VulkanFrameArenaGrowthDebugScene.cs new file mode 100644 index 0000000..280bba8 --- /dev/null +++ b/Promete.Example/examples/debug/VulkanFrameArenaGrowthDebugScene.cs @@ -0,0 +1,81 @@ +using System.Drawing; +using Promete.Example.Kernel; +using Promete.Input; +using Promete.Nodes; + +namespace Promete.Example.examples.debug; + +/// +/// レビュー指摘 #02 の再現シーン。 +/// VulkanFrameArena は全バッファを永続マップするが、容量超過で退役したバッファを +/// Reset()/Dispose() で破棄する際に UnmapMemory を呼ばない。 +/// マップ済みメモリの解放は未定義動作であり、バリデーションレイヤ有効時に検出される。 +/// +/// 1 フレームあたりのプッシュ量が初期容量 256 KB を超えるとアリーナが再確保され、 +/// 旧バッファが退役リストへ入る。矩形 1 個あたり頂点 32 B + インデックス 24 B、 +/// 16 B アライン込みで概ね 64 B 消費するため、4,096 個前後が境界となる。 +/// +[Demo("/debug/vulkan_frame_arena_growth", "指摘#02: アリーナ再確保でマップ済みメモリを解放")] +public class VulkanFrameArenaGrowthDebugScene(ConsoleLayer console, Keyboard keyboard) : Scene +{ + /// 初期容量 256 KB を超えない個数。 + private const int BelowThreshold = 2000; + + /// 初期容量を明確に超え、毎フレーム退役バッファを生む個数。 + private const int AboveThreshold = 8000; + + private readonly Container _container = new(); + + private int _shapeCount = BelowThreshold; + + public override void OnStart() + { + Root.Add(_container); + Rebuild(); + + console.Print("指摘#02 再現シーン"); + console.Print("バリデーションレイヤを有効にして実行すること"); + console.Print("SPACE: 図形数を 2000 <-> 8000 で切り替え"); + console.Print("8000 側でアリーナが 256KB を超えて再確保され、退役バッファが発生する"); + } + + public override void OnUpdate() + { + if (keyboard.Space.IsKeyDown) + { + _shapeCount = _shapeCount == BelowThreshold ? AboveThreshold : BelowThreshold; + Rebuild(); + } + + if (keyboard.Escape.IsKeyUp) + App.LoadScene(); + } + + /// + /// 指定個数の矩形を敷き詰め、1 フレームのプッシュ量を変化させる。 + /// + private void Rebuild() + { + _container.Clear(); + + const int columns = 100; + for (var i = 0; i < _shapeCount; i++) + { + var x = i % columns * 6; + var y = i / columns * 6; + var color = i % 2 == 0 ? Color.SteelBlue : Color.LightSalmon; + _container.Add(Shape.CreateRect(x, y, x + 4, y + 4, color)); + } + + var estimatedBytes = _shapeCount * 64; + console.Print( + $"図形数={_shapeCount} 推定プッシュ量={estimatedBytes / 1024}KB " + + (estimatedBytes > 256 * 1024 ? "(初期容量超過 → 再確保あり)" : "(容量内)") + ); + } + + public override void OnDestroy() + { + _container.Destroy(); + } +} diff --git a/Promete.Example/examples/debug/VulkanMaskRestoreDebugScene.cs b/Promete.Example/examples/debug/VulkanMaskRestoreDebugScene.cs new file mode 100644 index 0000000..c9dbaea --- /dev/null +++ b/Promete.Example/examples/debug/VulkanMaskRestoreDebugScene.cs @@ -0,0 +1,112 @@ +using System.Drawing; +using Promete.Example.Kernel; +using Promete.Graphics; +using Promete.Graphics.Rendering; +using Promete.Input; +using Promete.Nodes; + +namespace Promete.Example.examples.debug; + +/// +/// レビュー指摘 #05 の再現シーン。 +/// VulkanMaskedContainerHelper.RenderToTexture は MaskedContainer の Parent / Location / +/// Angle / Scale を一時的に退避してから子を描画するが、try/finally で保護していない。 +/// 子の Collect が例外を投げると復元ブロックへ到達せず、コンテナは親から切り離され +/// 原点・単位スケールのまま残る。 +/// +/// このシーンは 1 フレームだけ例外を投げる子ノードを仕込み、 +/// 例外の前後で MaskedContainer の変換が保たれるかを確認する。 +/// +[Demo("/debug/vulkan_mask_restore", "指摘#05: マスク描画中の例外で変換が復元されない")] +public class VulkanMaskRestoreDebugScene(ConsoleLayer console, Keyboard keyboard) : Scene +{ + private static readonly Vector InitialLocation = (200, 120); + + private Texture2D _backgroundTexture; + private Texture2D _maskTexture; + private MaskedContainer _masked = null!; + private ThrowingNode _thrower = null!; + + public override void OnStart() + { + _backgroundTexture = App.TextureFactory.Load("assets/ichigo2.png"); + _maskTexture = App.TextureFactory.Load("assets/circle_mask.png"); + + _masked = new MaskedContainer(_maskTexture, useAlphaMask: true) + .Location(InitialLocation) + .Scale(2, 2) + .Size(32, 32); + + _masked.Add(new Sprite(_backgroundTexture)); + + _thrower = new ThrowingNode(); + _masked.Add(_thrower); + + Root.Add(_masked); + + console.Print("指摘#05 再現シーン"); + console.Print("T: 次のフレームで子ノードに例外を投げさせる"); + console.Print("例外後、MaskedContainer の Location / Scale / Parent を検査する"); + PrintState("初期状態"); + } + + public override void OnUpdate() + { + if (keyboard.T.IsKeyDown) + { + _thrower.ShouldThrow = true; + console.Print("次フレームで例外を発生させる"); + } + + if (_thrower.HasThrown) + { + _thrower.HasThrown = false; + PrintState("例外発生後"); + } + + if (keyboard.Escape.IsKeyUp) + App.LoadScene(); + } + + /// + /// 退避された変換が復元されているかを表示する。 + /// 例外後に (0,0) / scale=1 / Parent=null になっていれば指摘#05 の再現。 + /// + private void PrintState(string label) + { + var hasParent = _masked.Parent is not null; + var restored = + _masked.Location == InitialLocation && _masked.Scale.X == 2 && hasParent; + + console.Print( + $"{label}: loc={_masked.Location} scale={_masked.Scale} parent={(hasParent ? "有" : "null")} " + + (restored ? "-> 復元OK" : "-> 復元されていない (指摘#05)") + ); + } + + public override void OnDestroy() + { + _masked.Destroy(); + _backgroundTexture.Dispose(); + _maskTexture.Dispose(); + } + + /// + /// Collect 時に一度だけ例外を投げるノード。 + /// + private sealed class ThrowingNode : Node + { + public bool ShouldThrow { get; set; } + public bool HasThrown { get; set; } + + public override void Collect(RenderCommandQueue queue, RenderContext ctx) + { + if (!ShouldThrow) + return; + + ShouldThrow = false; + HasThrown = true; + throw new InvalidOperationException("指摘#05 再現用の意図的な例外"); + } + } +} diff --git a/Promete.Example/examples/debug/VulkanMaterialSlotLeakDebugScene.cs b/Promete.Example/examples/debug/VulkanMaterialSlotLeakDebugScene.cs new file mode 100644 index 0000000..2d30afc --- /dev/null +++ b/Promete.Example/examples/debug/VulkanMaterialSlotLeakDebugScene.cs @@ -0,0 +1,128 @@ +using System.Drawing; +using Promete.Example.Kernel; +using Promete.Graphics; +using Promete.Input; +using Promete.Nodes; + +namespace Promete.Example.examples.debug; + +/// +/// レビュー指摘 #07 の再現シーン。 +/// VulkanMaterialSystem の _slots は (Material, フレームスロット) をキーに +/// UBO とディスクリプタセットを確保するが、Material がスコープを抜けても +/// エントリが除去されない。毎フレーム新しい Material を生成すると +/// プール上限 MaxSets = 1024 を消費し尽くし、AllocateDescriptorSets が +/// ErrorOutOfPoolMemory を投げる。 +/// +/// 1 フレームあたり 2 スロット (FramesInFlight = 2) 消費するため、 +/// 60fps では概ね 8.5 秒でクラッシュに到達する。 +/// +[Demo("/debug/vulkan_material_slot_leak", "指摘#07: Materialスロットが解放されずプール枯渇")] +public class VulkanMaterialSlotLeakDebugScene(ConsoleLayer console, Keyboard keyboard) : Scene +{ + private const string VertSrc = """ + #version 330 core + layout(location = 0) in vec2 vPos; + layout(location = 1) in vec2 vUv; + layout(location = 2) in vec4 iModel0; + layout(location = 3) in vec4 iModel1; + layout(location = 4) in vec4 iModel2; + layout(location = 5) in vec4 iModel3; + layout(location = 6) in vec4 iTintColor; + layout(location = 7) in vec4 iUvRect; + + out vec2 fUv; + out vec4 fTintColor; + + uniform mat4 uProjection; + + void main() + { + mat4 model = mat4(iModel0, iModel1, iModel2, iModel3); + gl_Position = uProjection * model * vec4(vPos, 0.0, 1.0); + fUv = mix(iUvRect.xy, iUvRect.zw, vUv); + fTintColor = iTintColor; + } + """; + + // uTime を持つだけの最小フラグメントシェーダー + private const string FragSrc = """ + #version 330 core + in vec2 fUv; + in vec4 fTintColor; + uniform sampler2D uTexture0; + uniform float uTime; + out vec4 FragColor; + + void main() + { + vec4 c = texture(uTexture0, fUv) * fTintColor; + FragColor = vec4(c.rgb * (0.5 + 0.5 * sin(uTime)), c.a); + } + """; + + private Texture2D _texture; + private ShaderProgram _shader = null!; + private Sprite _sprite = null!; + + /// 再利用する Material。リーク再現時は使用しない。 + private Material _sharedMaterial = null!; + + private bool _allocatePerFrame = true; + private int _frames; + + public override void OnStart() + { + _texture = App.TextureFactory.Load("assets/ichigo2.png"); + _shader = ShaderProgram.Create().Vertex(VertSrc).Fragment(FragSrc).Compile(); + _sharedMaterial = new Material(_shader) { ["uTime"] = 0f }; + + _sprite = new Sprite(_texture).Location(280, 180).Scale(4, 4); + Root.Add(_sprite); + + console.Print("指摘#07 再現シーン"); + console.Print("毎フレーム新しい Material を生成し、スロットを枯渇させる"); + console.Print("SPACE: 毎フレーム生成 / 使い回し を切り替え"); + console.Print("毎フレーム生成のまま放置すると数秒で ErrorOutOfPoolMemory で落ちる"); + } + + public override void OnUpdate() + { + if (keyboard.Space.IsKeyDown) + { + _allocatePerFrame = !_allocatePerFrame; + _frames = 0; + console.Print( + _allocatePerFrame + ? "毎フレーム Material を生成 (リーク再現)" + : "Material を使い回し (正常動作)" + ); + } + + var time = App.Time.TotalTime; + + if (_allocatePerFrame) + { + // 毎フレーム新しいインスタンスを作るため、_slots のキーが毎回変わる + _sprite.Material = new Material(_shader) { ["uTime"] = time }; + + _frames++; + if (_frames % 60 == 0) + console.Print($"経過フレーム={_frames} 推定消費スロット={_frames * 2} / 1024"); + } + else + { + _sharedMaterial["uTime"] = time; + _sprite.Material = _sharedMaterial; + } + + if (keyboard.Escape.IsKeyUp) + App.LoadScene(); + } + + public override void OnDestroy() + { + _sprite.Destroy(); + _texture.Dispose(); + } +} diff --git a/Promete.Example/examples/debug/VulkanShaderReflectionDebugScene.cs b/Promete.Example/examples/debug/VulkanShaderReflectionDebugScene.cs new file mode 100644 index 0000000..3362bfb --- /dev/null +++ b/Promete.Example/examples/debug/VulkanShaderReflectionDebugScene.cs @@ -0,0 +1,200 @@ +using System.Drawing; +using Promete.Example.Kernel; +using Promete.Graphics; +using Promete.Input; +using Promete.Nodes; + +namespace Promete.Example.examples.debug; + +/// +/// レビュー指摘 #06 / #08 の再現シーン。 +/// +/// #06: VulkanShaderManager の未対応 Uniform ブロック警告は fragBlocks しか検査しない。 +/// 頂点ステージで set != 1 の Uniform ブロックを宣言すると、値は merged から +/// 除外されるにもかかわらず警告が一切出ない。 +/// +/// #08: VulkanPipelineProvider は SPIR-V リフレクションで得た maxSet を上限チェックなしに +/// stackalloc のサイズへ渡す。巨大な set 番号を宣言したシェーダーでスタックが溢れる。 +/// デバイスの maxBoundDescriptorSets (通常 4〜8) でクランプすべき。 +/// +/// 注意: #08 のシェーダーは意図的にプロセスを巻き添えにする可能性がある。 +/// クランプ修正の検証用であり、通常のデモ操作では実行しないこと。 +/// +[Demo("/debug/vulkan_shader_reflection", "指摘#06/#08: シェーダーリフレクションの境界処理")] +public class VulkanShaderReflectionDebugScene(ConsoleLayer console, Keyboard keyboard) : Scene +{ + private const string StandardVertSrc = """ + #version 330 core + layout(location = 0) in vec2 vPos; + layout(location = 1) in vec2 vUv; + layout(location = 2) in vec4 iModel0; + layout(location = 3) in vec4 iModel1; + layout(location = 4) in vec4 iModel2; + layout(location = 5) in vec4 iModel3; + layout(location = 6) in vec4 iTintColor; + layout(location = 7) in vec4 iUvRect; + + out vec2 fUv; + out vec4 fTintColor; + + uniform mat4 uProjection; + + void main() + { + mat4 model = mat4(iModel0, iModel1, iModel2, iModel3); + gl_Position = uProjection * model * vec4(vPos, 0.0, 1.0); + fUv = mix(iUvRect.xy, iUvRect.zw, vUv); + fTintColor = iTintColor; + } + """; + + // 頂点ステージに set=2 の Uniform ブロックを持つシェーダー (指摘#06) + // set=1 ではないため merged から落ちるが、警告は出ない + private const string VertexUniformVertSrc = """ + #version 450 + layout(location = 0) in vec2 vPos; + layout(location = 1) in vec2 vUv; + layout(location = 2) in vec4 iModel0; + layout(location = 3) in vec4 iModel1; + layout(location = 4) in vec4 iModel2; + layout(location = 5) in vec4 iModel3; + layout(location = 6) in vec4 iTintColor; + layout(location = 7) in vec4 iUvRect; + + layout(location = 0) out vec2 fUv; + layout(location = 1) out vec4 fTintColor; + + uniform mat4 uProjection; + + // set=1 ではないため、この uWobble は静かに無視される + layout(set = 2, binding = 0) uniform VertexParams { + float uWobble; + }; + + void main() + { + mat4 model = mat4(iModel0, iModel1, iModel2, iModel3); + vec2 p = vPos + vec2(0.0, uWobble); + gl_Position = uProjection * model * vec4(p, 0.0, 1.0); + fUv = mix(iUvRect.xy, iUvRect.zw, vUv); + fTintColor = iTintColor; + } + """; + + private const string PlainFragSrc = """ + #version 450 + layout(location = 0) in vec2 fUv; + layout(location = 1) in vec4 fTintColor; + layout(set = 0, binding = 0) uniform sampler2D uTexture0; + layout(location = 0) out vec4 FragColor; + + void main() + { + FragColor = texture(uTexture0, fUv) * fTintColor; + } + """; + + // 巨大な set 番号を宣言するシェーダー (指摘#08) + private const string HugeSetFragSrc = """ + #version 450 + layout(location = 0) in vec2 fUv; + layout(location = 1) in vec4 fTintColor; + layout(set = 0, binding = 0) uniform sampler2D uTexture0; + layout(set = 100000, binding = 0) uniform sampler2D uUnreasonable; + layout(location = 0) out vec4 FragColor; + + void main() + { + FragColor = texture(uTexture0, fUv) * fTintColor + + texture(uUnreasonable, fUv) * 0.0; + } + """; + + private Texture2D _texture; + private Sprite _sprite = null!; + + public override void OnStart() + { + _texture = App.TextureFactory.Load("assets/ichigo2.png"); + _sprite = new Sprite(_texture).Location(280, 180).Scale(4, 4); + Root.Add(_sprite); + + console.Print("指摘#06 / #08 再現シーン"); + console.Print("1: 頂点ステージ set=2 の Uniform (指摘#06)"); + console.Print(" -> uWobble を設定しても効かず、警告も出ないことを確認"); + console.Print("9: set=100000 のサンプラー (指摘#08) ※スタックオーバーフローの危険"); + console.Print("0: 標準シェーダーへ戻す"); + } + + public override void OnUpdate() + { + if (keyboard.Number1.IsKeyDown) + TryVertexUniform(); + + if (keyboard.Number9.IsKeyDown) + TryHugeSet(); + + if (keyboard.Number0.IsKeyDown) + { + _sprite.Material = null; + console.Print("標準シェーダーへ戻した"); + } + + if (keyboard.Escape.IsKeyUp) + App.LoadScene(); + } + + /// + /// 指摘#06: 頂点ステージの set=2 Uniform が無警告で捨てられることを確認する。 + /// + private void TryVertexUniform() + { + try + { + var shader = ShaderProgram + .Create() + .Vertex(VertexUniformVertSrc) + .Fragment(PlainFragSrc) + .Compile(); + + _sprite.Material = new Material(shader) { ["uWobble"] = 32f }; + console.Print("set=2 の頂点 Uniform を適用した"); + console.Print("uWobble=32 が効いていなければ指摘#06 の再現 (警告なしで無視)"); + } + catch (Exception ex) + { + console.Print($"コンパイル失敗: {ex.Message}"); + } + } + + /// + /// 指摘#08: リフレクション由来の巨大 set 番号で stackalloc が破綻することを確認する。 + /// + private void TryHugeSet() + { + console.Print("set=100000 のシェーダーを構築中..."); + try + { + var shader = ShaderProgram + .Create() + .Vertex(StandardVertSrc) + .Fragment(HugeSetFragSrc) + .Compile(); + + _sprite.Material = new Material(shader); + console.Print("適用した。次の描画でパイプライン構築が走る"); + console.Print("ここで落ちる、または応答しなくなれば指摘#08 の再現"); + } + catch (Exception ex) + { + console.Print($"例外を捕捉: {ex.GetType().Name}: {ex.Message}"); + console.Print("明確なエラーで弾けていれば、クランプ修正が効いている"); + } + } + + public override void OnDestroy() + { + _sprite.Destroy(); + _texture.Dispose(); + } +} diff --git a/Promete.Test/SpirvReflectorTests.cs b/Promete.Test/SpirvReflectorTests.cs new file mode 100644 index 0000000..793ea04 --- /dev/null +++ b/Promete.Test/SpirvReflectorTests.cs @@ -0,0 +1,313 @@ +using System.Text; +using FluentAssertions; +using Promete.Graphics.Rendering.Vulkan; + +namespace Promete.Test; + +/// +/// のパース堅牢性テスト。 +/// +/// レビュー指摘 #09 に対応する。パースループは opcode に応じて words[index + 1] 〜 +/// words[index + 4] を読むが、ガードしているのは wordCount == 0 だけで、 +/// index + wordCount が配列長を超えないことも、命令長が読もうとするオペランド分 +/// あることも検査していない。そのため切り詰められた・不正な SPIR-V は +/// InvalidOperationException("不正な SPIR-V バイナリです。") ではなく +/// IndexOutOfRangeException になる。 +/// +/// 修正後は「不正な入力に対して InvalidOperationException を投げる」か +/// 「その命令を安全に読み飛ばす」のいずれかであるべき。 +/// IndexOutOfRangeException が出ている間はテストが失敗する。 +/// +public class SpirvReflectorTests +{ + private const uint MagicNumber = 0x07230203; + + private const uint OpName = 5; + private const uint OpMemberName = 6; + private const uint OpTypeImage = 25; + private const uint OpTypeStruct = 30; + private const uint OpTypePointer = 32; + private const uint OpVariable = 59; + private const uint OpDecorate = 71; + private const uint OpMemberDecorate = 72; + + private const uint DecorationBinding = 33; + private const uint DecorationDescriptorSet = 34; + private const uint DecorationOffset = 35; + + private const uint StorageClassUniformConstant = 0; + private const uint StorageClassUniform = 2; + + [Fact] + public void 正常なSPIRVからUniformブロックを抽出できる() + { + // 構造体型 10 に uColor(offset 0) / uTime(offset 16) を持ち、 + // set=1, binding=0 の Uniform 変数 30 として宣言する + var spirv = new SpirvBuilder() + .Add(OpName, 30, PackString("Params")) + .Add(OpMemberName, 10, 0, PackString("uColor")) + .Add(OpMemberName, 10, 1, PackString("uTime")) + .Add(OpMemberDecorate, 10, 0, DecorationOffset, 0) + .Add(OpMemberDecorate, 10, 1, DecorationOffset, 16) + .Add(OpDecorate, 30, DecorationDescriptorSet, 1) + .Add(OpDecorate, 30, DecorationBinding, 0) + .Add(OpTypeStruct, 10) + .Add(OpTypePointer, 20, StorageClassUniform, 10) + .Add(OpVariable, 20, 30, StorageClassUniform) + .Build(); + + var (blocks, _) = SpirvReflector.Reflect(spirv); + + blocks.Should().ContainSingle(); + blocks[0].Set.Should().Be(1); + blocks[0].Binding.Should().Be(0); + blocks[0].MemberOffsets.Should().Contain("uColor", 0u); + blocks[0].MemberOffsets.Should().Contain("uTime", 16u); + } + + [Fact] + public void 正常なSPIRVからサンプラーを抽出できる() + { + var spirv = new SpirvBuilder() + .Add(OpName, 31, PackString("uTexture0")) + .Add(OpDecorate, 31, DecorationDescriptorSet, 0) + .Add(OpDecorate, 31, DecorationBinding, 0) + .Add(OpTypeImage, 11) + .Add(OpTypePointer, 21, StorageClassUniformConstant, 11) + .Add(OpVariable, 21, 31, StorageClassUniformConstant) + .Build(); + + var (_, samplers) = SpirvReflector.Reflect(spirv); + + samplers.Should().ContainSingle(); + samplers[0].Name.Should().Be("uTexture0"); + samplers[0].Set.Should().Be(0); + } + + [Fact] + public void マジックナンバーが不正なら例外を投げる() + { + var spirv = new byte[5 * 4]; + BitConverter.GetBytes(0xDEADBEEFu).CopyTo(spirv, 0); + + var act = () => SpirvReflector.Reflect(spirv); + + act.Should().Throw(); + } + + [Fact] + public void ヘッダーより短い入力なら例外を投げる() + { + var spirv = new byte[3 * 4]; + BitConverter.GetBytes(MagicNumber).CopyTo(spirv, 0); + + var act = () => SpirvReflector.Reflect(spirv); + + act.Should().Throw(); + } + + /// + /// 指摘#09: 命令の宣言長が配列の残りを超えている場合。 + /// index += wordCount で範囲外へ飛ぶか、オペランド読み出しで境界を越える。 + /// + [Fact] + public void 命令長が配列末尾を超えていてもIndexOutOfRangeにならない() + { + // wordCount = 8 と宣言しつつ、実際には 2 ワードしか続かない OpTypeStruct + var words = new List { MagicNumber, 0x00010600, 8, 1, 0 }; + words.Add((8u << 16) | OpTypeStruct); + words.Add(10); + + var act = () => SpirvReflector.Reflect(ToBytes(words)); + + act.Should() + .NotThrow("切り詰められた SPIR-V は境界チェックで弾くべき"); + } + + /// + /// 指摘#09: OpMemberDecorate は words[index + 4] を読むが、命令長の検査がない。 + /// + [Fact] + public void OpMemberDecorateが短くてもIndexOutOfRangeにならない() + { + // 本来 wordCount 5 必要なところを 4 で宣言し、末尾に配置する + var words = new List { MagicNumber, 0x00010600, 8, 1, 0 }; + words.Add((4u << 16) | OpMemberDecorate); + words.Add(10); + words.Add(0); + words.Add(DecorationOffset); + + var act = () => SpirvReflector.Reflect(ToBytes(words)); + + act.Should() + .NotThrow( + "OpMemberDecorate は words[index + 4] を読む前に命令長を検査すべき" + ); + } + + /// + /// 指摘#09: OpVariable は words[index + 3] を読むが、命令長の検査がない。 + /// + [Fact] + public void OpVariableが短くてもIndexOutOfRangeにならない() + { + // 本来 wordCount 4 必要なところを 3 で宣言し、末尾に配置する + var words = new List { MagicNumber, 0x00010600, 8, 1, 0 }; + words.Add((3u << 16) | OpVariable); + words.Add(20); + words.Add(30); + + var act = () => SpirvReflector.Reflect(ToBytes(words)); + + act.Should() + .NotThrow( + "OpVariable は words[index + 3] を読む前に命令長を検査すべき" + ); + } + + /// + /// 指摘#09: OpTypePointer は words[index + 3] を読むが、命令長の検査がない。 + /// + [Fact] + public void OpTypePointerが短くてもIndexOutOfRangeにならない() + { + var words = new List { MagicNumber, 0x00010600, 8, 1, 0 }; + words.Add((3u << 16) | OpTypePointer); + words.Add(20); + words.Add(StorageClassUniform); + + var act = () => SpirvReflector.Reflect(ToBytes(words)); + + act.Should() + .NotThrow( + "OpTypePointer は words[index + 3] を読む前に命令長を検査すべき" + ); + } + + /// + /// 指摘#09: OpName の文字列読み出し範囲 (index + wordCount) が配列長を超えるケース。 + /// ReadString は範囲を信用して words[i] を読む。 + /// + [Fact] + public void OpNameの文字列が配列末尾を超えていてもIndexOutOfRangeにならない() + { + // wordCount = 16 と偽り、実際には 2 ワードしか存在しない + var words = new List { MagicNumber, 0x00010600, 8, 1, 0 }; + words.Add((16u << 16) | OpName); + words.Add(30); + + var act = () => SpirvReflector.Reflect(ToBytes(words)); + + act.Should() + .NotThrow( + "ReadString の終端は配列長でクランプすべき" + ); + } + + /// + /// 4 バイト境界に満たない入力。words 化の時点で情報が落ちる。 + /// + [Fact] + public void 語境界に満たない入力でもIndexOutOfRangeにならない() + { + var valid = new SpirvBuilder().Add(OpTypeStruct, 10).Build(); + var truncated = valid[..^3]; + + var act = () => SpirvReflector.Reflect(truncated); + + act.Should().NotThrow(); + } + + /// + /// 全 opcode を総当たりで末尾に配置し、境界越えが起きないことを確認する。 + /// どの命令にガード漏れがあっても検出できる。 + /// + [Theory] + [InlineData(OpName)] + [InlineData(OpMemberName)] + [InlineData(OpTypeImage)] + [InlineData(OpTypeStruct)] + [InlineData(OpTypePointer)] + [InlineData(OpVariable)] + [InlineData(OpDecorate)] + [InlineData(OpMemberDecorate)] + public void 各命令が単独で末尾にあってもIndexOutOfRangeにならない(uint opcode) + { + // オペランドを 1 つも伴わない wordCount = 1 の命令 + var words = new List { MagicNumber, 0x00010600, 8, 1, 0 }; + words.Add((1u << 16) | opcode); + + var act = () => SpirvReflector.Reflect(ToBytes(words)); + + act.Should() + .NotThrow( + $"opcode {opcode} はオペランド読み出し前に命令長を検査すべき" + ); + } + + private static byte[] ToBytes(List words) + { + var bytes = new byte[words.Count * 4]; + System.Buffer.BlockCopy(words.ToArray(), 0, bytes, 0, bytes.Length); + return bytes; + } + + /// + /// 文字列を SPIR-V のリテラル表現 (UTF-8 + NUL 終端 + 4 バイトパディング) に変換する。 + /// + private static uint[] PackString(string value) + { + var utf8 = Encoding.UTF8.GetBytes(value); + var wordCount = (utf8.Length / 4) + 1; + var padded = new byte[wordCount * 4]; + utf8.CopyTo(padded, 0); + + var words = new uint[wordCount]; + System.Buffer.BlockCopy(padded, 0, words, 0, padded.Length); + return words; + } + + /// + /// 正しい wordCount を自動計算しながら SPIR-V モジュールを組み立てる。 + /// + private sealed class SpirvBuilder + { + private readonly List _words = + [ + MagicNumber, + 0x00010600, // version 1.6 + 8, // generator + 100, // bound + 0, // schema + ]; + + public SpirvBuilder Add(uint opcode, params object[] operands) + { + var flat = new List(); + foreach (var operand in operands) + { + switch (operand) + { + case uint u: + flat.Add(u); + break; + case int i: + flat.Add((uint)i); + break; + case uint[] many: + flat.AddRange(many); + break; + default: + throw new ArgumentException($"未対応のオペランド型: {operand.GetType()}"); + } + } + + var wordCount = (uint)(flat.Count + 1); + _words.Add((wordCount << 16) | opcode); + _words.AddRange(flat); + return this; + } + + public byte[] Build() => ToBytes(_words); + } +} From 85d35913412de0a18f7b5efe44f9be63a03e8a18 Mon Sep 17 00:00:00 2001 From: Ebise Lutica <7106976+EbiseLutica@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:41:28 +0900 Subject: [PATCH 15/16] =?UTF-8?q?=E4=B8=8D=E5=85=B7=E5=90=88=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=E3=81=AA=E3=81=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Promete.Example/Program.cs | 7 + Promete.Example/Promete.Example.csproj | 1 + .../examples/debug/TrimClampDebugScene.cs | 14 +- .../debug/VulkanMaterialSlotLeakDebugScene.cs | 33 ++- .../debug/VulkanShaderReflectionDebugScene.cs | 55 +++-- Promete.Test/TrimCommandQueueTests.cs | 133 ++++++++++ Promete.Test/TrimSceneShapeTests.cs | 64 +++++ Promete.Test/VulkanDemoShaderCompileTests.cs | 231 ++++++++++++++++++ .../Promete.Vulkan.Validation.csproj | 27 ++ .../VulkanValidationExtension.cs | 26 ++ .../VulkanValidationHook.cs | 143 +++++++++++ .../VulkanValidationMessage.cs | 19 ++ Promete.sln | 14 ++ .../Backends/Vulkan/VulkanDesktopBackend.cs | 4 +- .../GL/Runners/GLEndTrimCommandRunner.cs | 13 +- .../Graphics/Rendering/RenderCommandQueue.cs | 16 ++ .../Rendering/Vulkan/IVulkanInstanceHook.cs | 55 +++++ .../Runners/VulkanTrimCommandRunners.cs | 10 +- .../Rendering/Vulkan/VulkanContext.cs | 104 +++++++- .../Vulkan/VulkanPipelineProvider.cs | 14 ++ .../Rendering/Vulkan/VulkanShaderManager.cs | 7 +- 21 files changed, 930 insertions(+), 60 deletions(-) create mode 100644 Promete.Test/TrimCommandQueueTests.cs create mode 100644 Promete.Test/TrimSceneShapeTests.cs create mode 100644 Promete.Test/VulkanDemoShaderCompileTests.cs create mode 100644 Promete.Vulkan.Validation/Promete.Vulkan.Validation.csproj create mode 100644 Promete.Vulkan.Validation/VulkanValidationExtension.cs create mode 100644 Promete.Vulkan.Validation/VulkanValidationHook.cs create mode 100644 Promete.Vulkan.Validation/VulkanValidationMessage.cs create mode 100644 Promete/Graphics/Rendering/Vulkan/IVulkanInstanceHook.cs diff --git a/Promete.Example/Program.cs b/Promete.Example/Program.cs index 6a35062..8339243 100644 --- a/Promete.Example/Program.cs +++ b/Promete.Example/Program.cs @@ -5,12 +5,16 @@ using Promete.GLDesktop; using Promete.ImGui; using Promete.Input; +using Promete.Vulkan.Validation; using Promete.VulkanDesktop; using Promete.Windowing; // --vulkan フラグで実験的な Vulkan バックエンドを使用する DemoKernel.UseVulkan = args.Contains("--vulkan"); +// --validation フラグで Vulkan バリデーションレイヤーを有効化する (要 Vulkan SDK) +var useValidation = args.Contains("--validation"); + var builder = PrometeApp .Create() .Use() @@ -20,6 +24,9 @@ .Use() .Use(); +if (DemoKernel.UseVulkan && useValidation) + builder = builder.UseVulkanValidation(); + var options = WindowOptions.Default with { Title = DemoKernel.UseVulkan ? "Promete Demo (Vulkan)" : "Promete Demo", diff --git a/Promete.Example/Promete.Example.csproj b/Promete.Example/Promete.Example.csproj index b815bd2..40df09f 100644 --- a/Promete.Example/Promete.Example.csproj +++ b/Promete.Example/Promete.Example.csproj @@ -11,6 +11,7 @@ + diff --git a/Promete.Example/examples/debug/TrimClampDebugScene.cs b/Promete.Example/examples/debug/TrimClampDebugScene.cs index 2e0530e..fd65b7a 100644 --- a/Promete.Example/examples/debug/TrimClampDebugScene.cs +++ b/Promete.Example/examples/debug/TrimClampDebugScene.cs @@ -39,7 +39,9 @@ public class TrimClampDebugScene(ConsoleLayer console, Keyboard keyboard) : Scen /// 内側コンテナの画面上の左端。外側の可視領域内に置く。 private const int InnerScreenX = 50; - /// 外側コンテナ。左へはみ出させてクリップ幅を過大にする。 + /// + /// 外側コンテナ。自身は中身を持たず、左へはみ出させてクリップ幅を過大にする役に徹する。 + /// private readonly Container _outer = new Container().Size(OuterWidth, TrimHeight); /// 内側コンテナ。中身を全域に持ち、外側の過大な幅の影響を受ける。 @@ -68,15 +70,7 @@ public override void OnStart() ); } - // 外側の中身。内側との重なりを避けるため上端の薄い帯だけにする - for (var x = 0; x < OuterWidth; x += 20) - { - var isEven = x / 20 % 2 == 0; - _outer.Add( - Shape.CreateRect(x, 0, x + 19, 10, isEven ? Color.SteelBlue : Color.DarkSlateBlue) - ); - } - + // 外側自身は中身を持たない。トリム枠としてのみ機能させる _outer.Add(_inner); Root.Add(_outer); diff --git a/Promete.Example/examples/debug/VulkanMaterialSlotLeakDebugScene.cs b/Promete.Example/examples/debug/VulkanMaterialSlotLeakDebugScene.cs index 2d30afc..5764beb 100644 --- a/Promete.Example/examples/debug/VulkanMaterialSlotLeakDebugScene.cs +++ b/Promete.Example/examples/debug/VulkanMaterialSlotLeakDebugScene.cs @@ -20,8 +20,9 @@ namespace Promete.Example.examples.debug; [Demo("/debug/vulkan_material_slot_leak", "指摘#07: Materialスロットが解放されずプール枯渇")] public class VulkanMaterialSlotLeakDebugScene(ConsoleLayer console, Keyboard keyboard) : Scene { + // Vulkan 向け。uProjection は push constant、in/out はすべて location 必須。 private const string VertSrc = """ - #version 330 core + #version 450 layout(location = 0) in vec2 vPos; layout(location = 1) in vec2 vUv; layout(location = 2) in vec4 iModel0; @@ -31,10 +32,13 @@ public class VulkanMaterialSlotLeakDebugScene(ConsoleLayer console, Keyboard key layout(location = 6) in vec4 iTintColor; layout(location = 7) in vec4 iUvRect; - out vec2 fUv; - out vec4 fTintColor; + layout(location = 0) out vec2 fUv; + layout(location = 1) out vec4 fTintColor; - uniform mat4 uProjection; + layout(push_constant) uniform PushConstants + { + mat4 uProjection; + }; void main() { @@ -45,14 +49,21 @@ void main() } """; - // uTime を持つだけの最小フラグメントシェーダー + // uTime を持つだけの最小フラグメントシェーダー。 + // Material の名前ベース Uniform は set=1, binding=0 のブロックに置く規約。 private const string FragSrc = """ - #version 330 core - in vec2 fUv; - in vec4 fTintColor; - uniform sampler2D uTexture0; - uniform float uTime; - out vec4 FragColor; + #version 450 + layout(location = 0) in vec2 fUv; + layout(location = 1) in vec4 fTintColor; + + layout(set = 0, binding = 0) uniform sampler2D uTexture0; + + layout(set = 1, binding = 0) uniform MaterialParams + { + float uTime; + }; + + layout(location = 0) out vec4 FragColor; void main() { diff --git a/Promete.Example/examples/debug/VulkanShaderReflectionDebugScene.cs b/Promete.Example/examples/debug/VulkanShaderReflectionDebugScene.cs index 3362bfb..992d1a3 100644 --- a/Promete.Example/examples/debug/VulkanShaderReflectionDebugScene.cs +++ b/Promete.Example/examples/debug/VulkanShaderReflectionDebugScene.cs @@ -14,17 +14,24 @@ namespace Promete.Example.examples.debug; /// 除外されるにもかかわらず警告が一切出ない。 /// /// #08: VulkanPipelineProvider は SPIR-V リフレクションで得た maxSet を上限チェックなしに -/// stackalloc のサイズへ渡す。巨大な set 番号を宣言したシェーダーでスタックが溢れる。 -/// デバイスの maxBoundDescriptorSets (通常 4〜8) でクランプすべき。 +/// stackalloc のサイズへ渡す。デバイスの maxBoundDescriptorSets (通常 4〜8) で +/// クランプすべきだが、していない。 /// -/// 注意: #08 のシェーダーは意図的にプロセスを巻き添えにする可能性がある。 -/// クランプ修正の検証用であり、通常のデモ操作では実行しないこと。 +/// 当初「スタックオーバーフローする」と評価したが、これは誤り。 +/// shaderc が set >= 255 を拒否するため stackalloc は最大 2KB 程度にとどまる。 +/// +/// 実際の危険はより深刻で、デバイスの maxBoundDescriptorSets を超える +/// セット数を CreatePipelineLayout に渡すこと。仕様違反 (VUID-...-00286) であり、 +/// バリデーションレイヤ不在の環境ではドライバ内でアクセス違反 (0xC0000005) により +/// プロセスが即死する。修正後は事前に InvalidOperationException で弾く。 /// [Demo("/debug/vulkan_shader_reflection", "指摘#06/#08: シェーダーリフレクションの境界処理")] public class VulkanShaderReflectionDebugScene(ConsoleLayer console, Keyboard keyboard) : Scene { + // Vulkan 向けの標準頂点シェーダー。 + // uProjection は push constant (64 バイト = mat4)、in/out はすべて location 必須。 private const string StandardVertSrc = """ - #version 330 core + #version 450 layout(location = 0) in vec2 vPos; layout(location = 1) in vec2 vUv; layout(location = 2) in vec4 iModel0; @@ -34,10 +41,13 @@ public class VulkanShaderReflectionDebugScene(ConsoleLayer console, Keyboard key layout(location = 6) in vec4 iTintColor; layout(location = 7) in vec4 iUvRect; - out vec2 fUv; - out vec4 fTintColor; + layout(location = 0) out vec2 fUv; + layout(location = 1) out vec4 fTintColor; - uniform mat4 uProjection; + layout(push_constant) uniform PushConstants + { + mat4 uProjection; + }; void main() { @@ -64,7 +74,10 @@ void main() layout(location = 0) out vec2 fUv; layout(location = 1) out vec4 fTintColor; - uniform mat4 uProjection; + layout(push_constant) uniform PushConstants + { + mat4 uProjection; + }; // set=1 ではないため、この uWobble は静かに無視される layout(set = 2, binding = 0) uniform VertexParams { @@ -94,13 +107,21 @@ void main() } """; - // 巨大な set 番号を宣言するシェーダー (指摘#08) + // 大きな set 番号を宣言するシェーダー (指摘#08)。 + // shaderc が 255 以上の set を 'set is too large' で拒否するため、 + // GLSL 経由で宣言できるのは 32〜254 未満の範囲にとどまる。 + // よって stackalloc は最大でも DescriptorSetLayout(8バイト) × 255 = 約 2KB で、 + // スタックオーバーフローには至らない。 + // + // 実際の危険は maxBoundDescriptorSets (多くの実装で 4〜8、環境により 32) の超過。 + // 修正前はこの値を検査せず CreatePipelineLayout に渡していたため、 + // バリデーションレイヤ不在の環境ではドライバ内でアクセス違反 (0xC0000005) を起こした。 private const string HugeSetFragSrc = """ #version 450 layout(location = 0) in vec2 fUv; layout(location = 1) in vec4 fTintColor; layout(set = 0, binding = 0) uniform sampler2D uTexture0; - layout(set = 100000, binding = 0) uniform sampler2D uUnreasonable; + layout(set = 32, binding = 0) uniform sampler2D uUnreasonable; layout(location = 0) out vec4 FragColor; void main() @@ -122,7 +143,7 @@ public override void OnStart() console.Print("指摘#06 / #08 再現シーン"); console.Print("1: 頂点ステージ set=2 の Uniform (指摘#06)"); console.Print(" -> uWobble を設定しても効かず、警告も出ないことを確認"); - console.Print("9: set=100000 のサンプラー (指摘#08) ※スタックオーバーフローの危険"); + console.Print("9: set=32 のサンプラー (指摘#08) デバイス上限超過を弾けるか"); console.Print("0: 標準シェーダーへ戻す"); } @@ -168,11 +189,11 @@ private void TryVertexUniform() } /// - /// 指摘#08: リフレクション由来の巨大 set 番号で stackalloc が破綻することを確認する。 + /// 指摘#08: デバイス上限を超える set 番号が、事前に弾かれるかを確認する。 /// private void TryHugeSet() { - console.Print("set=100000 のシェーダーを構築中..."); + console.Print("set=32 のシェーダーを構築中..."); try { var shader = ShaderProgram @@ -183,15 +204,17 @@ private void TryHugeSet() _sprite.Material = new Material(shader); console.Print("適用した。次の描画でパイプライン構築が走る"); - console.Print("ここで落ちる、または応答しなくなれば指摘#08 の再現"); + console.Print("修正前はここでドライバ内アクセス違反 (0xC0000005) により即死する"); } catch (Exception ex) { console.Print($"例外を捕捉: {ex.GetType().Name}: {ex.Message}"); - console.Print("明確なエラーで弾けていれば、クランプ修正が効いている"); } } + // 補足: パイプライン構築は描画時に走るため、修正後の InvalidOperationException は + // ここではなく OnRender 経由で送出される。コンソールではなく標準の例外として現れる。 + public override void OnDestroy() { _sprite.Destroy(); diff --git a/Promete.Test/TrimCommandQueueTests.cs b/Promete.Test/TrimCommandQueueTests.cs new file mode 100644 index 0000000..179a4d8 --- /dev/null +++ b/Promete.Test/TrimCommandQueueTests.cs @@ -0,0 +1,133 @@ +using FluentAssertions; +using Promete.Graphics.Rendering; +using Promete.Graphics.Rendering.Commands; +using Promete.Nodes; + +namespace Promete.Test; + +/// +/// のトリム矩形計算テスト。 +/// +/// レビュー指摘 #03 の回帰防止。原点を 0 にクランプする際にサイズを縮めないと、 +/// 可視領域がはみ出した量だけ広がり、入れ子トリムの積集合にも漏れる。 +/// +public class TrimCommandQueueTests +{ + private static readonly VectorInt WindowSize = (640, 480); + + [Fact] + public void 画面内に収まるトリムはそのまま出力される() + { + var command = PushTrim(location: (100, 100), size: (200, 150)); + + command.X.Should().Be(100); + command.Y.Should().Be(100); + command.Width.Should().Be(200); + command.Height.Should().Be(150); + } + + [Fact] + public void 左上にはみ出した分だけ幅と高さが縮む() + { + var command = PushTrim(location: (-50, -40), size: (200, 150)); + + command.X.Should().Be(0); + command.Y.Should().Be(0); + command.Width.Should().Be(150, "左に 50 はみ出した分は可視幅から引かれる"); + command.Height.Should().Be(110, "上に 40 はみ出した分は可視高から引かれる"); + } + + [Fact] + public void 大きくはみ出しても可視領域は正しく狭まる() + { + var command = PushTrim(location: (-400, 100), size: (500, 150)); + + command.X.Should().Be(0); + command.Width.Should().Be(100); + } + + [Fact] + public void 完全に画面外へ出た場合は幅がゼロになる() + { + var command = PushTrim(location: (-600, 100), size: (500, 150)); + + command.X.Should().Be(0); + command.Width.Should().Be(0, "負の幅を出力してはならない"); + } + + [Fact] + public void 右下がウィンドウを超える場合はウィンドウ端で切られる() + { + var command = PushTrim(location: (-100, 100), size: (800, 150)); + + command.X.Should().Be(0); + command.Width.Should().Be(640); + } + + /// + /// 指摘#03 の本質。外側の過大な幅が内側の積集合へ漏れないことを確認する。 + /// + [Fact] + public void 入れ子トリムで外側のはみ出しが内側の右端に反映される() + { + var queue = new RenderCommandQueue(); + var captured = new List(); + queue.RegisterRunner(new CapturingRunner(captured)); + + var ctx = new RenderContext { WindowSize = WindowSize }; + + // 外側: 左へ 200 はみ出す。可視領域は x 0..200 であるべき + var outer = new Container().Size(400, 150); + outer.Location = (-200, 140); + + // 内側: 画面 x=50 に置く。中身は x 50..350 に広がる + var inner = new Container().Size(300, 150); + inner.Location = (250, 0); + outer.Add(inner); + + outer.BeforeRender(); + inner.BeforeRender(); + + queue.PushTrim(outer, ctx); + queue.PushTrim(inner, ctx); + queue.ProcessAndFlush(); + + captured.Should().HaveCount(2); + + var outerCommand = captured[0]; + outerCommand.X.Should().Be(0); + outerCommand.Width.Should().Be(200, "外側の可視幅は 400 - 200 = 200"); + + var innerCommand = captured[1]; + var innerRight = innerCommand.X + innerCommand.Width; + innerRight + .Should() + .Be(200, "内側は外側の可視右端 200 で切られる (過大な 350 になってはならない)"); + } + + /// + /// 指定位置・サイズのコンテナをトリムし、生成された BeginTrimCommand を返す。 + /// + private static BeginTrimCommand PushTrim(VectorInt location, VectorInt size) + { + var queue = new RenderCommandQueue(); + var captured = new List(); + queue.RegisterRunner(new CapturingRunner(captured)); + + var container = new Container().Size(size); + container.Location = location; + container.BeforeRender(); + + queue.PushTrim(container, new RenderContext { WindowSize = WindowSize }); + queue.ProcessAndFlush(); + + captured.Should().ContainSingle(); + return captured[0]; + } + + private sealed class CapturingRunner(List sink) + : CommandRunner + { + public override void Execute(BeginTrimCommand command) => sink.Add(command); + } +} diff --git a/Promete.Test/TrimSceneShapeTests.cs b/Promete.Test/TrimSceneShapeTests.cs new file mode 100644 index 0000000..46610b9 --- /dev/null +++ b/Promete.Test/TrimSceneShapeTests.cs @@ -0,0 +1,64 @@ +using FluentAssertions; +using Promete.Graphics.Rendering; +using Promete.Graphics.Rendering.Commands; +using Promete.Nodes; +using Xunit.Abstractions; + +namespace Promete.Test; + +/// +/// デモシーン TrimClampDebugScene と同じノード構成でトリム矩形を検証する。 +/// 画面で見えている挙動が意図通りかを確認するための診断テスト。 +/// +public class TrimSceneShapeTests(ITestOutputHelper output) +{ + private const int TrimTop = 140; + private const int TrimHeight = 150; + private const int OuterWidth = 400; + private const int InnerWidth = 300; + private const int InnerScreenX = 50; + + [Theory] + [InlineData(0)] + [InlineData(-100)] + [InlineData(-200)] + public void デモシーン構成のトリム矩形を出力する(int outerX) + { + var queue = new RenderCommandQueue(); + var captured = new List(); + queue.RegisterRunner(new CapturingRunner(captured)); + + var outer = new Container().Size(OuterWidth, TrimHeight); + outer.Location = (outerX, TrimTop); + + var inner = new Container().Size(InnerWidth, TrimHeight); + inner.Location = (InnerScreenX - outerX, 0); + outer.Add(inner); + + outer.BeforeRender(); + inner.BeforeRender(); + + var ctx = new RenderContext { WindowSize = (640, 480) }; + queue.PushTrim(outer, ctx); + queue.PushTrim(inner, ctx); + queue.ProcessAndFlush(); + + var o = captured[0]; + var i = captured[1]; + + output.WriteLine($"outerX = {outerX}"); + output.WriteLine($" 外側トリム: x={o.X}..{o.X + o.Width} (青紫の帯はここで切れる)"); + output.WriteLine($" 内側トリム: x={i.X}..{i.X + i.Width} (緑の帯はここで切れる)"); + output.WriteLine($" 青紫の帯の実体: 画面 x={outerX}..{outerX + OuterWidth}"); + output.WriteLine($" 緑の帯の実体 : 画面 x={InnerScreenX}..{InnerScreenX + InnerWidth}"); + + // 外側の帯は外側トリムを超えて見えてはならない + (o.X + o.Width).Should().BeLessThanOrEqualTo(outerX + OuterWidth); + } + + private sealed class CapturingRunner(List sink) + : CommandRunner + { + public override void Execute(BeginTrimCommand command) => sink.Add(command); + } +} diff --git a/Promete.Test/VulkanDemoShaderCompileTests.cs b/Promete.Test/VulkanDemoShaderCompileTests.cs new file mode 100644 index 0000000..53935b9 --- /dev/null +++ b/Promete.Test/VulkanDemoShaderCompileTests.cs @@ -0,0 +1,231 @@ +using FluentAssertions; +using Promete.Graphics.Rendering.Vulkan; +using Silk.NET.Shaderc; + +namespace Promete.Test; + +/// +/// デバッグデモが使うカスタムシェーダーが、Vulkan 方言の GLSL として +/// 実際にコンパイルできることを確認する。 +/// +/// シェーダーのコンパイルは実行時に行われるため、C# のビルドが通っても +/// シェーダーの誤りは検出されない。デモを起動せずに検証するためのテスト。 +/// +/// Vulkan の規約: +/// - #version 450 +/// - in / out はすべて location 指定が必須 +/// - 非 opaque な uniform はブロックに入れる (裸の uniform mat4 は不可) +/// - uProjection は push constant (64 バイト = mat4) +/// +public class VulkanDemoShaderCompileTests +{ + /// デモが使う標準頂点シェーダー。 + private const string StandardVert = """ + #version 450 + layout(location = 0) in vec2 vPos; + layout(location = 1) in vec2 vUv; + layout(location = 2) in vec4 iModel0; + layout(location = 3) in vec4 iModel1; + layout(location = 4) in vec4 iModel2; + layout(location = 5) in vec4 iModel3; + layout(location = 6) in vec4 iTintColor; + layout(location = 7) in vec4 iUvRect; + + layout(location = 0) out vec2 fUv; + layout(location = 1) out vec4 fTintColor; + + layout(push_constant) uniform PushConstants + { + mat4 uProjection; + }; + + void main() + { + mat4 model = mat4(iModel0, iModel1, iModel2, iModel3); + gl_Position = uProjection * model * vec4(vPos, 0.0, 1.0); + fUv = mix(iUvRect.xy, iUvRect.zw, vUv); + fTintColor = iTintColor; + } + """; + + /// 指摘#06 用。頂点ステージに set=2 の Uniform ブロックを持つ。 + private const string VertexUniformVert = """ + #version 450 + layout(location = 0) in vec2 vPos; + layout(location = 1) in vec2 vUv; + layout(location = 2) in vec4 iModel0; + layout(location = 3) in vec4 iModel1; + layout(location = 4) in vec4 iModel2; + layout(location = 5) in vec4 iModel3; + layout(location = 6) in vec4 iTintColor; + layout(location = 7) in vec4 iUvRect; + + layout(location = 0) out vec2 fUv; + layout(location = 1) out vec4 fTintColor; + + layout(push_constant) uniform PushConstants + { + mat4 uProjection; + }; + + layout(set = 2, binding = 0) uniform VertexParams { + float uWobble; + }; + + void main() + { + mat4 model = mat4(iModel0, iModel1, iModel2, iModel3); + vec2 p = vPos + vec2(0.0, uWobble); + gl_Position = uProjection * model * vec4(p, 0.0, 1.0); + fUv = mix(iUvRect.xy, iUvRect.zw, vUv); + fTintColor = iTintColor; + } + """; + + private const string PlainFrag = """ + #version 450 + layout(location = 0) in vec2 fUv; + layout(location = 1) in vec4 fTintColor; + layout(set = 0, binding = 0) uniform sampler2D uTexture0; + layout(location = 0) out vec4 FragColor; + + void main() + { + FragColor = texture(uTexture0, fUv) * fTintColor; + } + """; + + /// 指摘#08 用。GLSL で到達可能な上限付近の set 番号を宣言する。 + private const string HugeSetFrag = """ + #version 450 + layout(location = 0) in vec2 fUv; + layout(location = 1) in vec4 fTintColor; + layout(set = 0, binding = 0) uniform sampler2D uTexture0; + layout(set = 32, binding = 0) uniform sampler2D uUnreasonable; + layout(location = 0) out vec4 FragColor; + + void main() + { + FragColor = texture(uTexture0, fUv) * fTintColor + + texture(uUnreasonable, fUv) * 0.0; + } + """; + + /// 指摘#07 用。Material Uniform を set=1, binding=0 に置く。 + private const string MaterialFrag = """ + #version 450 + layout(location = 0) in vec2 fUv; + layout(location = 1) in vec4 fTintColor; + + layout(set = 0, binding = 0) uniform sampler2D uTexture0; + + layout(set = 1, binding = 0) uniform MaterialParams + { + float uTime; + }; + + layout(location = 0) out vec4 FragColor; + + void main() + { + vec4 c = texture(uTexture0, fUv) * fTintColor; + FragColor = vec4(c.rgb * (0.5 + 0.5 * sin(uTime)), c.a); + } + """; + + [Theory] + [InlineData("StandardVert", ShaderKind.VertexShader)] + [InlineData("VertexUniformVert", ShaderKind.VertexShader)] + [InlineData("PlainFrag", ShaderKind.FragmentShader)] + [InlineData("HugeSetFrag", ShaderKind.FragmentShader)] + [InlineData("MaterialFrag", ShaderKind.FragmentShader)] + public void デモのシェーダーがコンパイルできる(string name, ShaderKind kind) + { + var source = name switch + { + "StandardVert" => StandardVert, + "VertexUniformVert" => VertexUniformVert, + "PlainFrag" => PlainFrag, + "HugeSetFrag" => HugeSetFrag, + "MaterialFrag" => MaterialFrag, + _ => throw new ArgumentOutOfRangeException(nameof(name)), + }; + + using var compiler = new VulkanShaderCompiler(); + + var act = () => compiler.Compile(source, kind, $"{name}.glsl"); + + act.Should().NotThrow(); + } + + /// + /// 指摘#06 のシェーダーが、意図通り set=2 の Uniform ブロックとして + /// リフレクションに現れることを確認する。 + /// + [Fact] + public void 指摘06のシェーダーはset2のUniformブロックを持つ() + { + using var compiler = new VulkanShaderCompiler(); + var spirv = compiler.Compile(VertexUniformVert, ShaderKind.VertexShader, "wobble.vert"); + + var (blocks, _) = SpirvReflector.Reflect(spirv); + + blocks + .Should() + .Contain( + b => b.Set == 2 && b.Binding == 0, + "set=1 以外のブロックは merged から落ちるが、警告が出ない (指摘#06)" + ); + } + + /// + /// 指摘#06 の修正確認。未対応ブロックの検査は両ステージを対象にする必要がある。 + /// 頂点ステージのみに set!=1 のブロックがある場合、フラグメント側だけを見ると + /// 検出できないことを示す。 + /// + [Fact] + public void 未対応ブロックの検査は両ステージを見なければ検出できない() + { + using var compiler = new VulkanShaderCompiler(); + + var vertSpv = compiler.Compile(VertexUniformVert, ShaderKind.VertexShader, "wobble.vert"); + var fragSpv = compiler.Compile(PlainFrag, ShaderKind.FragmentShader, "plain.frag"); + + var (vertBlocks, _) = SpirvReflector.Reflect(vertSpv); + var (fragBlocks, _) = SpirvReflector.Reflect(fragSpv); + + // 修正前の判定 (フラグメントのみ): 検出できない + fragBlocks + .Should() + .NotContain( + b => b.Set != 1 || b.Binding != 0, + "フラグメント側だけを見ると未対応ブロックを見逃す (修正前の挙動)" + ); + + // 修正後の判定 (両ステージ): 検出できる + vertBlocks + .Concat(fragBlocks) + .Should() + .Contain( + b => b.Set != 1 || b.Binding != 0, + "両ステージを見れば頂点側の set=2 ブロックを検出できる" + ); + } + + /// + /// 指摘#08 のシェーダーが、上限付近の set 番号をリフレクションに載せることを確認する。 + /// この値が VulkanPipelineProvider のレイアウト生成へ渡る。 + /// + [Fact] + public void 指摘08のシェーダーは上限付近のset番号を報告する() + { + using var compiler = new VulkanShaderCompiler(); + var spirv = compiler.Compile(HugeSetFrag, ShaderKind.FragmentShader, "huge.frag"); + + var (_, samplers) = SpirvReflector.Reflect(spirv); + + samplers + .Should() + .Contain(s => s.Set == 32, "この値が上限チェックなしに stackalloc とレイアウト生成へ渡る (指摘#08)"); + } +} diff --git a/Promete.Vulkan.Validation/Promete.Vulkan.Validation.csproj b/Promete.Vulkan.Validation/Promete.Vulkan.Validation.csproj new file mode 100644 index 0000000..cfd21b8 --- /dev/null +++ b/Promete.Vulkan.Validation/Promete.Vulkan.Validation.csproj @@ -0,0 +1,27 @@ + + + net10.0 + 14 + enable + enable + true + + Promete.Vulkan.Validation + 2.0.0-beta.8 + Ebise Lutica + + Promete の Vulkan バックエンド用バリデーションレイヤープラグイン。 + 開発時のみ参照し、リリースビルドでは参照を外してください。 + + MIT + true + + + + + + + + + + diff --git a/Promete.Vulkan.Validation/VulkanValidationExtension.cs b/Promete.Vulkan.Validation/VulkanValidationExtension.cs new file mode 100644 index 0000000..e7a4e1e --- /dev/null +++ b/Promete.Vulkan.Validation/VulkanValidationExtension.cs @@ -0,0 +1,26 @@ +using Promete.Graphics.Rendering.Vulkan; + +namespace Promete.Vulkan.Validation; + +/// +/// Vulkan バリデーションレイヤーを有効化する拡張メソッドを提供します。 +/// +public static class VulkanValidationExtension +{ + /// + /// Vulkan のバリデーションレイヤーを有効化します。 + /// BuildWithVulkanDesktop より前に呼び出してください。 + /// + /// + /// 開発時にのみ使用してください。バリデーションレイヤーは描画性能を大きく低下させます。 + /// Vulkan SDK が未インストールの環境では、警告を出したうえで無効のまま動作します。 + /// + /// PrometeApp のビルダー。 + /// 同じビルダー。 + public static PrometeApp.PrometeAppBuilder UseVulkanValidation( + this PrometeApp.PrometeAppBuilder builder + ) + { + return builder.Use(); + } +} diff --git a/Promete.Vulkan.Validation/VulkanValidationHook.cs b/Promete.Vulkan.Validation/VulkanValidationHook.cs new file mode 100644 index 0000000..0b057db --- /dev/null +++ b/Promete.Vulkan.Validation/VulkanValidationHook.cs @@ -0,0 +1,143 @@ +using Promete.Graphics.Rendering.Vulkan; +using Silk.NET.Core.Native; +using Silk.NET.Vulkan; +using Silk.NET.Vulkan.Extensions.EXT; + +namespace Promete.Vulkan.Validation; + +/// +/// Vulkan のバリデーションレイヤーを有効化し、その指摘を出力するプラグインです。 +/// +/// +/// +/// 開発時にのみ使用してください。バリデーションレイヤーは描画性能を大きく低下させます。 +/// リリースビルドではこのパッケージへの参照ごと外すことを推奨します。 +/// +/// +/// 使用には Vulkan SDK のインストールが必要です (https://vulkan.lunarg.com/sdk/home)。 +/// レイヤーが見つからない場合は警告を出したうえで、何もせず動作を続けます。 +/// +/// +/// PrometeApp.Create() +/// .UseVulkanValidation() +/// .BuildWithVulkanDesktop(); +/// +/// +public sealed unsafe class VulkanValidationHook : IVulkanInstanceHook, IDisposable +{ + private const string ValidationLayerName = "VK_LAYER_KHRONOS_validation"; + + /// + /// 静的コールバックから参照するための現在のインスタンス。 + /// + private static VulkanValidationHook? _current; + + private ExtDebugUtils? _debugUtils; + private DebugUtilsMessengerEXT _messenger; + private bool _disposed; + + /// + /// バリデーションメッセージを受け取ったときに呼び出されます。 + /// 未設定の場合は標準エラー出力へ書き出します。 + /// + public Action? OnMessage { get; set; } + + /// + public IEnumerable GetRequestedLayers() => [ValidationLayerName]; + + /// + public IEnumerable GetRequestedExtensions() => [ExtDebugUtils.ExtensionName]; + + /// + public void OnInstanceCreated(Vk vk, Instance instance) + { + if (!vk.TryGetInstanceExtension(instance, out ExtDebugUtils debugUtils)) + { + Console.Error.WriteLine( + "[Promete] VK_EXT_debug_utils を取得できませんでした。バリデーションメッセージは表示されません。" + ); + return; + } + + _debugUtils = debugUtils; + + var createInfo = new DebugUtilsMessengerCreateInfoEXT + { + SType = StructureType.DebugUtilsMessengerCreateInfoExt, + MessageSeverity = + DebugUtilsMessageSeverityFlagsEXT.WarningBitExt + | DebugUtilsMessageSeverityFlagsEXT.ErrorBitExt, + MessageType = + DebugUtilsMessageTypeFlagsEXT.GeneralBitExt + | DebugUtilsMessageTypeFlagsEXT.ValidationBitExt + | DebugUtilsMessageTypeFlagsEXT.PerformanceBitExt, + PfnUserCallback = (DebugUtilsMessengerCallbackFunctionEXT)DebugCallback, + }; + + // コールバックは静的関数ポインタとして渡るため、インスタンスを静的に保持する。 + // プラグインはシングルトンとして登録されるので、実質 1 つに限られる + _current = this; + + var result = _debugUtils.CreateDebugUtilsMessenger( + instance, + in createInfo, + null, + out _messenger + ); + + if (result != Result.Success) + { + Console.Error.WriteLine($"[Promete] デバッグメッセンジャーの作成に失敗しました: {result}"); + _debugUtils.Dispose(); + _debugUtils = null; + } + } + + /// + public void OnInstanceDestroying(Vk vk, Instance instance) + { + if (_debugUtils is null) + return; + + _debugUtils.DestroyDebugUtilsMessenger(instance, _messenger, null); + _debugUtils.Dispose(); + _debugUtils = null; + + if (ReferenceEquals(_current, this)) + _current = null; + } + + /// + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + + // インスタンス破棄は OnInstanceDestroying で行うため、ここでは参照を落とすのみ + _debugUtils = null; + if (ReferenceEquals(_current, this)) + _current = null; + } + + private static uint DebugCallback( + DebugUtilsMessageSeverityFlagsEXT severity, + DebugUtilsMessageTypeFlagsEXT type, + DebugUtilsMessengerCallbackDataEXT* callbackData, + void* userData + ) + { + var text = SilkMarshal.PtrToString((nint)callbackData->PMessage) ?? string.Empty; + var level = severity.HasFlag(DebugUtilsMessageSeverityFlagsEXT.ErrorBitExt) + ? VulkanValidationLevel.Error + : VulkanValidationLevel.Warning; + + var handler = _current?.OnMessage; + if (handler is not null) + handler(new VulkanValidationMessage(level, text)); + else + Console.Error.WriteLine($"[Vulkan {level}] {text}"); + + return Vk.False; + } +} diff --git a/Promete.Vulkan.Validation/VulkanValidationMessage.cs b/Promete.Vulkan.Validation/VulkanValidationMessage.cs new file mode 100644 index 0000000..bc89629 --- /dev/null +++ b/Promete.Vulkan.Validation/VulkanValidationMessage.cs @@ -0,0 +1,19 @@ +namespace Promete.Vulkan.Validation; + +/// バリデーションメッセージの深刻度。 +public enum VulkanValidationLevel +{ + /// 警告。 + Warning, + + /// エラー。仕様違反や未定義動作を示します。 + Error, +} + +/// バリデーションレイヤーからのメッセージ。 +/// 深刻度。 +/// 本文。 +public readonly record struct VulkanValidationMessage( + VulkanValidationLevel Level, + string Message +); diff --git a/Promete.sln b/Promete.sln index e284288..d00909b 100644 --- a/Promete.sln +++ b/Promete.sln @@ -19,6 +19,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Promete.Test", "Promete.Tes EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Promete.Experimental.Vulkan", "Promete.Experimental.Vulkan\Promete.Experimental.Vulkan.csproj", "{86C96AD0-8BED-4C90-B562-C008ED5A1253}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Promete.Vulkan.Validation", "Promete.Vulkan.Validation\Promete.Vulkan.Validation.csproj", "{F4FAC4A8-29C7-4CAB-8B15-9F4B509C5BF2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -123,6 +125,18 @@ Global {86C96AD0-8BED-4C90-B562-C008ED5A1253}.Release|x64.Build.0 = Release|Any CPU {86C96AD0-8BED-4C90-B562-C008ED5A1253}.Release|x86.ActiveCfg = Release|Any CPU {86C96AD0-8BED-4C90-B562-C008ED5A1253}.Release|x86.Build.0 = Release|Any CPU + {F4FAC4A8-29C7-4CAB-8B15-9F4B509C5BF2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F4FAC4A8-29C7-4CAB-8B15-9F4B509C5BF2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F4FAC4A8-29C7-4CAB-8B15-9F4B509C5BF2}.Debug|x64.ActiveCfg = Debug|Any CPU + {F4FAC4A8-29C7-4CAB-8B15-9F4B509C5BF2}.Debug|x64.Build.0 = Debug|Any CPU + {F4FAC4A8-29C7-4CAB-8B15-9F4B509C5BF2}.Debug|x86.ActiveCfg = Debug|Any CPU + {F4FAC4A8-29C7-4CAB-8B15-9F4B509C5BF2}.Debug|x86.Build.0 = Debug|Any CPU + {F4FAC4A8-29C7-4CAB-8B15-9F4B509C5BF2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F4FAC4A8-29C7-4CAB-8B15-9F4B509C5BF2}.Release|Any CPU.Build.0 = Release|Any CPU + {F4FAC4A8-29C7-4CAB-8B15-9F4B509C5BF2}.Release|x64.ActiveCfg = Release|Any CPU + {F4FAC4A8-29C7-4CAB-8B15-9F4B509C5BF2}.Release|x64.Build.0 = Release|Any CPU + {F4FAC4A8-29C7-4CAB-8B15-9F4B509C5BF2}.Release|x86.ActiveCfg = Release|Any CPU + {F4FAC4A8-29C7-4CAB-8B15-9F4B509C5BF2}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Promete/Backends/Vulkan/VulkanDesktopBackend.cs b/Promete/Backends/Vulkan/VulkanDesktopBackend.cs index f2852e6..7322abe 100644 --- a/Promete/Backends/Vulkan/VulkanDesktopBackend.cs +++ b/Promete/Backends/Vulkan/VulkanDesktopBackend.cs @@ -64,7 +64,9 @@ public override void OnInitialize(PrometeApp app, WindowOptions opts) _nativeWindow.Update += _ => _app.OnUpdate(); _nativeWindow.Closing += OnClosing; - _context = new VulkanContext(_nativeWindow); + // インスタンスフックが登録されていれば使う (バリデーションレイヤー等) + _app.TryGetPlugin(out var instanceHook); + _context = new VulkanContext(_nativeWindow, instanceHook); _resources = new VulkanResourceManager(_context); _shaderManager = new VulkanShaderManager(_context); _materialSystem = new VulkanMaterialSystem(_context, _shaderManager, _resources); diff --git a/Promete/Graphics/Rendering/GL/Runners/GLEndTrimCommandRunner.cs b/Promete/Graphics/Rendering/GL/Runners/GLEndTrimCommandRunner.cs index 02b8b68..701881b 100644 --- a/Promete/Graphics/Rendering/GL/Runners/GLEndTrimCommandRunner.cs +++ b/Promete/Graphics/Rendering/GL/Runners/GLEndTrimCommandRunner.cs @@ -16,14 +16,19 @@ public override void Execute(EndTrimCommand command) { var gl = _view.GL; + // トリムが無効に戻る場合、コマンドはゼロ埋めのセンチネル。 + // 反転すると範囲外の原点になるため、Scissor は発行せず無効化のみ行う + if (!command.WasEnabled) + { + gl.Disable(GLEnum.ScissorTest); + return; + } + // コマンドの座標は左上原点。GL の Scissor は左下原点なので Y を反転する var viewportHeight = GLHelper.GetViewport(gl).Y; var flippedY = viewportHeight - command.Y - command.Height; gl.Scissor(command.X, flippedY, (uint)command.Width, (uint)command.Height); - if (command.WasEnabled) - gl.Enable(GLEnum.ScissorTest); - else - gl.Disable(GLEnum.ScissorTest); + gl.Enable(GLEnum.ScissorTest); } } diff --git a/Promete/Graphics/Rendering/RenderCommandQueue.cs b/Promete/Graphics/Rendering/RenderCommandQueue.cs index d76b0d4..fcd1be9 100644 --- a/Promete/Graphics/Rendering/RenderCommandQueue.cs +++ b/Promete/Graphics/Rendering/RenderCommandQueue.cs @@ -94,10 +94,20 @@ public void PushTrim(ContainableNode node, RenderContext ctx) var left = (VectorInt)node.AbsoluteLocation; var size = (VectorInt)(node.Size * node.AbsoluteScale); + // 原点を 0 にクランプする際は、切り詰めた分だけサイズも縮める。 + // 縮めないと可視領域がはみ出した量だけ右下へ広がり、 + // 入れ子トリムの積集合にもその過大な領域が漏れる。 if (left.X < 0) + { + size.X += left.X; left.X = 0; + } + if (left.Y < 0) + { + size.Y += left.Y; left.Y = 0; + } if (left.X + size.X > ctx.WindowSize.X) size.X = ctx.WindowSize.X - left.X; @@ -105,6 +115,12 @@ public void PushTrim(ContainableNode node, RenderContext ctx) if (left.Y + size.Y > ctx.WindowSize.Y) size.Y = ctx.WindowSize.Y - left.Y; + // 完全に画面外へ出た場合は負になりうるため、0 で下限を取る + if (size.X < 0) + size.X = 0; + if (size.Y < 0) + size.Y = 0; + // 座標は左上原点で保持する。バックエンド固有の変換(GL の左下原点への Y 反転など)はランナー側で行う var sx = (float)left.X; var sy = (float)left.Y; diff --git a/Promete/Graphics/Rendering/Vulkan/IVulkanInstanceHook.cs b/Promete/Graphics/Rendering/Vulkan/IVulkanInstanceHook.cs new file mode 100644 index 0000000..0248775 --- /dev/null +++ b/Promete/Graphics/Rendering/Vulkan/IVulkanInstanceHook.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using Silk.NET.Vulkan; + +namespace Promete.Graphics.Rendering.Vulkan; + +/// +/// Vulkan インスタンスの生成に割り込むためのフックです。 +/// レイヤーや拡張の追加、インスタンス生成後の初期化を行えます。 +/// +/// +/// +/// これは Promete 内部のプラグイン用 API です。通常のゲーム開発で実装しないでください。 +/// Vulkan のインスタンス生成に直接影響するため、誤った実装は初期化の失敗や +/// 未定義動作を招きます。予告なく変更される場合があります。 +/// +/// +/// バリデーションレイヤーのようなデバッグ機能を本体から切り離すために用意されています。 +/// 実装を DI コンテナへ登録すると、Vulkan バックエンドが初期化時に呼び出します。 +/// 登録しなければ関連するコードもアセンブリも一切読み込まれません。 +/// +/// +/// PrometeApp.Create() +/// .Use<IVulkanInstanceHook, VulkanValidationHook>() +/// .BuildWithVulkanDesktop(); +/// +/// +public interface IVulkanInstanceHook +{ + /// + /// 有効化したいインスタンスレイヤーの名前を返します。 + /// 実際に利用できないレイヤーは呼び出し側で除外されます。 + /// + public IEnumerable GetRequestedLayers(); + + /// + /// 有効化したいインスタンス拡張の名前を返します。 + /// が 1 つも有効にならなかった場合は呼び出されません。 + /// + public IEnumerable GetRequestedExtensions(); + + /// + /// インスタンスの生成直後に呼び出されます。 + /// + /// Vulkan API のエントリポイント。 + /// 生成されたインスタンス。 + public void OnInstanceCreated(Vk vk, Instance instance); + + /// + /// インスタンスの破棄直前に呼び出されます。 + /// で確保したリソースを解放してください。 + /// + /// Vulkan API のエントリポイント。 + /// 破棄されるインスタンス。 + public void OnInstanceDestroying(Vk vk, Instance instance); +} diff --git a/Promete/Graphics/Rendering/Vulkan/Runners/VulkanTrimCommandRunners.cs b/Promete/Graphics/Rendering/Vulkan/Runners/VulkanTrimCommandRunners.cs index cdaaf3f..fe569fb 100644 --- a/Promete/Graphics/Rendering/Vulkan/Runners/VulkanTrimCommandRunners.cs +++ b/Promete/Graphics/Rendering/Vulkan/Runners/VulkanTrimCommandRunners.cs @@ -18,12 +18,14 @@ public override void Execute(BeginTrimCommand command) ctx.SetTrimScissor(ToScissor(command.X, command.Y, command.Width, command.Height)); } + /// + /// トリム矩形をシザー矩形へ変換します。 + /// レンダーターゲット範囲へのクランプは 側で行うため、 + /// ここでは負の値を潰さずそのまま渡します。 + /// internal static Rect2D ToScissor(int x, int y, int width, int height) { - return new Rect2D( - new Offset2D(Math.Max(0, x), Math.Max(0, y)), - new Extent2D((uint)Math.Max(0, width), (uint)Math.Max(0, height)) - ); + return new Rect2D(new Offset2D(x, y), new Extent2D((uint)Math.Max(0, width), (uint)Math.Max(0, height))); } } diff --git a/Promete/Graphics/Rendering/Vulkan/VulkanContext.cs b/Promete/Graphics/Rendering/Vulkan/VulkanContext.cs index 82d338e..89b7a65 100644 --- a/Promete/Graphics/Rendering/Vulkan/VulkanContext.cs +++ b/Promete/Graphics/Rendering/Vulkan/VulkanContext.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Drawing; +using Promete.Internal; using Silk.NET.Core; using Silk.NET.Core.Native; using Silk.NET.Vulkan; @@ -25,12 +26,14 @@ internal sealed unsafe class VulkanContext : IDisposable public const Format OffscreenFormat = Format.R8G8B8A8Unorm; private readonly IWindow _window; + private readonly IVulkanInstanceHook? _hook; private readonly Stack _targetStack = new(); private readonly List[] _deferredDestroys = new List[FramesInFlight]; private KhrSurface _khrSurface = null!; private KhrSwapchain _khrSwapchain = null!; + private Instance _instance; private SurfaceKHR _surface; private PhysicalDevice _physicalDevice; @@ -66,9 +69,10 @@ internal sealed unsafe class VulkanContext : IDisposable private bool _framebufferResized; private bool _disposed; - public VulkanContext(IWindow window) + public VulkanContext(IWindow window, IVulkanInstanceHook? hook = null) { _window = window; + _hook = hook; _window.FramebufferResize += _ => _framebufferResized = true; for (var i = 0; i < FramesInFlight; i++) _deferredDestroys[i] = []; @@ -83,6 +87,12 @@ public VulkanContext(IWindow window) /// 物理デバイスを取得します。 public PhysicalDevice PhysicalDevice => _physicalDevice; + /// + /// パイプラインレイアウトにバインドできるディスクリプタセットの最大数を取得します。 + /// 多くの実装では 4〜8 です。 + /// + public uint MaxBoundDescriptorSets { get; private set; } + /// グラフィックス兼プレゼントキューを取得します。 public Queue GraphicsQueue => _graphicsQueue; @@ -709,6 +719,10 @@ public void Dispose() vk.DestroyRenderPass(_device, _offscreenLoadPass, null); vk.DestroyDevice(_device, null); _khrSurface.DestroySurface(_instance, _surface, null); + + // フックが確保したリソースはインスタンスより先に解放させる + _hook?.OnInstanceDestroying(vk, _instance); + vk.DestroyInstance(_instance, null); _khrSwapchain.Dispose(); @@ -776,17 +790,42 @@ private void ApplyCurrentScissor() if (!_frameActive) return; var cmd = CurrentCommandBuffer; + var extent = CurrentTargetExtent; + if (TrimScissor is { } trim) { - Vk.CmdSetScissor(cmd, 0, 1, in trim); + // シザーは現在のレンダーターゲット内に収まっていなければならない + // (VUID-vkCmdSetScissor-x-00595)。トリム矩形はウィンドウ基準で + // 計算されるため、より小さいオフスクリーンターゲットでは超過しうる。 + var clamped = ClampToExtent(trim, extent); + Vk.CmdSetScissor(cmd, 0, 1, in clamped); } else { - var full = new Rect2D(new Offset2D(0, 0), CurrentTargetExtent); + var full = new Rect2D(new Offset2D(0, 0), extent); Vk.CmdSetScissor(cmd, 0, 1, in full); } } + /// + /// シザー矩形をレンダーターゲットの範囲内へ収めます。 + /// 負のオフセットは 0 に寄せ、その分だけ範囲を縮めます。 + /// + private static Rect2D ClampToExtent(Rect2D rect, Extent2D extent) + { + var left = Math.Max(0, rect.Offset.X); + var top = Math.Max(0, rect.Offset.Y); + + // 元の右端・下端を保ったままターゲット内へクリップする + var right = Math.Min((long)rect.Offset.X + rect.Extent.Width, extent.Width); + var bottom = Math.Min((long)rect.Offset.Y + rect.Extent.Height, extent.Height); + + var width = (uint)Math.Max(0, right - left); + var height = (uint)Math.Max(0, bottom - top); + + return new Rect2D(new Offset2D(left, top), new Extent2D(width, height)); + } + private void FlushDeferredDestroys(int slot) { var list = _deferredDestroys[slot]; @@ -817,17 +856,32 @@ private void CreateInstance(string appName) var surfaceExtensions = _window.VkSurface!.GetRequiredExtensions(out var extensionCount); - var enabledLayers = GetAvailableValidationLayers(); + // フックが要求するレイヤーのうち、実際に利用できるものだけを有効化する + var enabledLayers = FilterAvailableLayers(_hook?.GetRequestedLayers()); var layersPtr = enabledLayers.Length > 0 ? (byte**)SilkMarshal.StringArrayToPtr(enabledLayers) : null; + var extensions = new List(); + for (var i = 0u; i < extensionCount; i++) + extensions.Add(SilkMarshal.PtrToString((nint)surfaceExtensions[i])!); + + // レイヤーが 1 つも有効にならなかった場合、付随する拡張も不要 + if (enabledLayers.Length > 0 && _hook is not null) + { + foreach (var extension in _hook.GetRequestedExtensions()) + if (!extensions.Contains(extension)) + extensions.Add(extension); + } + + var extensionsPtr = (byte**)SilkMarshal.StringArrayToPtr(extensions); + var createInfo = new InstanceCreateInfo { SType = StructureType.InstanceCreateInfo, PApplicationInfo = &appInfo, - EnabledExtensionCount = extensionCount, - PpEnabledExtensionNames = surfaceExtensions, + EnabledExtensionCount = (uint)extensions.Count, + PpEnabledExtensionNames = extensionsPtr, EnabledLayerCount = (uint)enabledLayers.Length, PpEnabledLayerNames = layersPtr, }; @@ -836,6 +890,7 @@ private void CreateInstance(string appName) SilkMarshal.Free((nint)appNamePtr); SilkMarshal.Free((nint)engineNamePtr); + SilkMarshal.Free((nint)extensionsPtr); if (layersPtr != null) SilkMarshal.Free((nint)layersPtr); @@ -843,12 +898,22 @@ private void CreateInstance(string appName) if (!vk.TryGetInstanceExtension(_instance, out _khrSurface)) throw new InvalidOperationException("VK_KHR_surface 拡張が利用できません。"); + + if (enabledLayers.Length > 0) + _hook?.OnInstanceCreated(vk, _instance); } - private string[] GetAvailableValidationLayers() + /// + /// 要求されたレイヤーのうち、この環境で実際に利用できるものだけを返します。 + /// + private string[] FilterAvailableLayers(IEnumerable? requested) { -#if DEBUG - const string validationLayerName = "VK_LAYER_KHRONOS_validation"; + if (requested is null) + return []; + + var wanted = new List(requested); + if (wanted.Count == 0) + return []; var vk = Vk; uint layerCount = 0; @@ -859,14 +924,24 @@ private string[] GetAvailableValidationLayers() vk.EnumerateInstanceLayerProperties(ref layerCount, p); } + var available = new HashSet(); foreach (var layer in layers) { var name = SilkMarshal.PtrToString((nint)layer.LayerName); - if (name == validationLayerName) - return [validationLayerName]; + if (name is not null) + available.Add(name); + } + + var result = new List(); + foreach (var name in wanted) + { + if (available.Contains(name)) + result.Add(name); + else + LogHelper.Warn($"Vulkan レイヤー {name} は利用できないため無視します。"); } -#endif - return []; + + return result.ToArray(); } private void CreateSurface() @@ -927,6 +1002,9 @@ private void SelectPhysicalDevice(PhysicalDevice device, uint queueFamily) _physicalDevice = device; _queueFamilyIndex = queueFamily; Vk.GetPhysicalDeviceMemoryProperties(device, out _memoryProperties); + + Vk.GetPhysicalDeviceProperties(device, out var properties); + MaxBoundDescriptorSets = properties.Limits.MaxBoundDescriptorSets; } private bool TryFindQueueFamily(PhysicalDevice device, out uint queueFamilyIndex) diff --git a/Promete/Graphics/Rendering/Vulkan/VulkanPipelineProvider.cs b/Promete/Graphics/Rendering/Vulkan/VulkanPipelineProvider.cs index 153d933..983b24e 100644 --- a/Promete/Graphics/Rendering/Vulkan/VulkanPipelineProvider.cs +++ b/Promete/Graphics/Rendering/Vulkan/VulkanPipelineProvider.cs @@ -547,6 +547,20 @@ private PipelineLayout CreateCustomLayout(CustomKind kind, uint maxSet) var uboSetLayout = _materials.UboSetLayout; var setCount = Math.Max(2u, maxSet + 1); + + // デバイス上限を超える set 数を渡すと仕様違反となり、 + // バリデーションレイヤ不在の環境ではドライバ内でアクセス違反を起こす + // (VUID-VkPipelineLayoutCreateInfo-setLayoutCount-00286)。 + var maxSets = _ctx.MaxBoundDescriptorSets; + if (setCount > maxSets) + { + throw new InvalidOperationException( + $"カスタムシェーダーが descriptor set {maxSet} を宣言しており、" + + $"{setCount} 個のセットが必要ですが、このデバイスがバインドできるのは " + + $"{maxSets} 個までです (指定できる set 番号は 0 〜 {maxSets - 1})。" + ); + } + var setLayouts = stackalloc DescriptorSetLayout[(int)setCount]; setLayouts[0] = kind == CustomKind.Primitive ? _emptySetLayout : textureSetLayout; setLayouts[1] = uboSetLayout; diff --git a/Promete/Graphics/Rendering/Vulkan/VulkanShaderManager.cs b/Promete/Graphics/Rendering/Vulkan/VulkanShaderManager.cs index 62d81f4..e9be7fc 100644 --- a/Promete/Graphics/Rendering/Vulkan/VulkanShaderManager.cs +++ b/Promete/Graphics/Rendering/Vulkan/VulkanShaderManager.cs @@ -56,7 +56,12 @@ public int Compile(string vertexSource, string fragmentSource, string name) }; } - var unsupported = fragBlocks.FirstOrDefault(b => b is not { Set: 1, Binding: 0 }); + // 両ステージを対象に検査する。頂点ステージのブロックも同じ規約で + // merged から除外されるため、警告しないと無言で無視される + var unsupported = vertBlocks + .Concat(fragBlocks) + .FirstOrDefault(b => b is not { Set: 1, Binding: 0 }); + if (unsupported is not null) { LogHelper.Bug( From 2d412a96940a4c4bdd43fdfa537a3c4689446c8f Mon Sep 17 00:00:00 2001 From: Ebise Lutica <7106976+EbiseLutica@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:38:59 +0900 Subject: [PATCH 16/16] =?UTF-8?q?chore:=20=E3=83=90=E3=83=BC=E3=82=B8?= =?UTF-8?q?=E3=83=A7=E3=83=B3=E3=82=92=202.0.0-beta.9=20=E3=81=AB=E6=9B=B4?= =?UTF-8?q?=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Promete/Promete.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Promete/Promete.csproj b/Promete/Promete.csproj index 36c7ceb..5a50ef6 100644 --- a/Promete/Promete.csproj +++ b/Promete/Promete.csproj @@ -10,7 +10,7 @@ Promete - 2.0.0-beta.8 + 2.0.0-beta.9 Game Engine;2D;gamedev;games;gaming;windowing;OpenGL A 2D-specified, lightweight, extensible and easy-to-use game engine.