Fix Vulkan/OpenGL attachmentless rendering

Some fragment passes used by FFT do not have any color or depth/stencil attachments. Instead, the fragment shader writes the results directly to storage images (on OpenGL: through imageStore).

### Vulkan
When SetImage is called and FramebufferParams has no attachments, its virtual size must be updated using the width, height, and layer count of the fragment storage image. Otherwise, the attachmentless framebuffer may retain an incorrect 1x1 extent, causing backgrounds, logos, UI elements, and other rendered content to be missing.

If the first draw occurs before the storage-image descriptor is rebound, SetImage cannot yet provide the correct storage-image dimensions. Therefore, when RecreateGraphicsPipelineIfNeeded finds that FramebufferParams has no attachments and still uses the default 1x1 extent, it initializes the framebuffer dimensions from the active viewport. Otherwise, the first Vulkan draw may be restricted to a 1x1 area and render incorrectly.

The storage-image extent is therefore used as the authoritative size for FramebufferParams, while the active viewport is used as a fallback when the storage-image extent is not yet available before the first draw.

### OpenGL
A storage image is not a framebuffer attachment, so OpenGL cannot derive the framebuffer width and height from it. When the framebuffer has no attachments but a viewport with valid dimensions has already been defined, Pipeline.PreDraw must set the default framebuffer width and height from the active viewport.

Without non-zero default width and height values, the attachmentless framebuffer remains incomplete. Drawing with that framebuffer results in InvalidFramebufferOperation, so rasterization and fragment shader execution do not occur even when the storage images are bound correctly.

(cherry picked from commit ac6db0fe76)
This commit is contained in:
avan
2026-08-24 13:58:00 -05:00
committed by KeatonTheBot
parent db4799e7ab
commit 2dc1894e1e
4 changed files with 87 additions and 2 deletions
+36 -2
View File
@@ -20,6 +20,27 @@ namespace Ryujinx.Graphics.OpenGL
private int _colorsCount;
private bool _dualSourceBlend;
public bool HasAttachments
{
get
{
if (_depthStencil != null)
{
return true;
}
for (int index = 0; index < _colors.Length; index++)
{
if (_colors[index] != null)
{
return true;
}
}
return false;
}
}
public Framebuffer()
{
Handle = GL.GenFramebuffer();
@@ -34,6 +55,12 @@ namespace Ryujinx.Graphics.OpenGL
return Handle;
}
public void SetDefaultSize(int width, int height)
{
GL.FramebufferParameter(FramebufferTarget.Framebuffer, FramebufferDefaultParameter.FramebufferDefaultWidth, Math.Max(1, width));
GL.FramebufferParameter(FramebufferTarget.Framebuffer, FramebufferDefaultParameter.FramebufferDefaultHeight, Math.Max(1, height));
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void AttachColor(int index, TextureView color)
{
@@ -105,13 +132,20 @@ namespace Ryujinx.Graphics.OpenGL
_colorsCount = colorsCount;
}
private static void SetDrawBuffersImpl(int colorsCount)
private void SetDrawBuffersImpl(int colorsCount)
{
DrawBuffersEnum[] drawBuffers = new DrawBuffersEnum[colorsCount];
for (int index = 0; index < colorsCount; index++)
{
drawBuffers[index] = DrawBuffersEnum.ColorAttachment0 + index;
if (_colors[index] != null)
{
drawBuffers[index] = DrawBuffersEnum.ColorAttachment0 + index;
}
else
{
drawBuffers[index] = DrawBuffersEnum.None;
}
}
GL.DrawBuffers(colorsCount, drawBuffers);
+5
View File
@@ -1537,6 +1537,11 @@ namespace Ryujinx.Graphics.OpenGL
{
DrawCount++;
if (!_framebuffer.HasAttachments && _viewportArray.Length >= 4)
{
_framebuffer.SetDefaultSize((int)_viewportArray[2], (int)_viewportArray[3]);
}
_unit0Texture?.Bind(0);
}
@@ -32,6 +32,29 @@ namespace Ryujinx.Graphics.Vulkan
public bool HasDepthStencil { get; private set; }
public int ColorAttachmentsCount => AttachmentsCount - (HasDepthStencil ? 1 : 0);
public bool SetVirtualSize(uint width, uint height, uint layers)
{
if (AttachmentsCount != 0)
{
return false;
}
width = Math.Max(1u, width);
height = Math.Max(1u, height);
layers = Math.Max(1u, layers);
if (Width == width && Height == height && Layers == layers)
{
return false;
}
Width = width;
Height = height;
Layers = layers;
return true;
}
public FramebufferParams(Device device, TextureView view, uint width, uint height)
{
Format format = view.Info.Format;
@@ -843,6 +843,11 @@ namespace Ryujinx.Graphics.Vulkan
public void SetImage(ShaderStage stage, int binding, ITexture image)
{
_descriptorSetUpdater.SetImage(Cbs, stage, binding, image);
if (stage == ShaderStage.Fragment && image is TextureView view)
{
FramebufferParams?.SetVirtualSize((uint)view.Width, (uint)view.Height, (uint)view.Layers);
}
}
public void SetImage(int binding, Auto<DisposableImageView> image)
@@ -1607,6 +1612,24 @@ namespace Ryujinx.Graphics.Vulkan
private bool RecreateGraphicsPipelineIfNeeded()
{
if (FramebufferParams != null &&
FramebufferParams.AttachmentsCount == 0 &&
FramebufferParams.Width == 1 &&
FramebufferParams.Height == 1 &&
DynamicState.ViewportsCount != 0)
{
// An attachmentless fragment pass can reach its first draw before the storage
// image descriptor is rebound. At that point the null framebuffer still has
// its constructor fallback of 1x1, even though the guest viewport already
// describes the real render area. Seed the virtual framebuffer from that
// viewport so the first storage-image draw is not clipped to one pixel.
Silk.NET.Vulkan.Viewport viewport = DynamicState.Viewports[0];
uint width = (uint)Math.Max(1f, Math.Abs(viewport.Width));
uint height = (uint)Math.Max(1f, Math.Abs(viewport.Height));
FramebufferParams.SetVirtualSize(width, height, 1);
}
if (AutoFlush.ShouldFlushDraw(DrawCount))
{
Gd.FlushAllCommands();