Skip to content

feat: ask the graphics device what it provides, instead of comparing profiles - #3368

Open
sasvdw wants to merge 13 commits into
stride3d:masterfrom
LazyWorksZA:spike/graphics-requirements
Open

feat: ask the graphics device what it provides, instead of comparing profiles#3368
sasvdw wants to merge 13 commits into
stride3d:masterfrom
LazyWorksZA:spike/graphics-requirements

Conversation

@sasvdw

@sasvdw sasvdw commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

PR Details

Summary — Adds a capability vocabulary to Stride.Graphics, so a renderer asks what the device
provides instead of comparing graphics profiles.

What it adds

Three questions that Stride currently answers with one number:

GraphicsBackend.Implements(kind)      // static, per backend, needs no device
features.Provides(capability)         // the hardware and the driver
features.Supports(capability)         // both, three-valued

GraphicsCapabilitySupport is Available, NotProvidedByDevice or NotImplementedByBackend. That
third state is the point. The two failures have different remedies — another device fixes one, and
only a change to Stride fixes the other — and there was no way to say so. The Vulkan backend has never
implemented multisampling, and today reports it as hardware that cannot do it.

A GraphicsCapability owns its identity and how to read it from GraphicsDeviceFeatures, so a
renderer states what it needs rather than whether it has it. Two kinds so far: a device feature flag,
and Multisampling(format, count), which is per pixel format and graded.

Why

A feature-level ladder assumes capabilities are ordered and cumulative. Raytracing, mesh shaders and
variable-rate shading are optional and orthogonal — a card can be feature level 12_1 with no
raytracing. A profile comparison cannot ask about those, and it cannot tell hardware that will not do
something from a backend that does not do it yet.

One behaviour change

VoxelRenderer now asks for compute shaders instead of comparing profiles. HasComputeShaders is
queried only on Direct3D 11 and hardcoded true on the other backends, so voxelization now runs where
the profile comparison refused it. The capability is also coarser than voxelization needs, because the
set cannot yet say shader model 5. This one needs a review.

Call sites

The engine compares a profile against a level in twenty-five places. Seven now ask the device. The
eighteen that stay are three different things, and only one of them is "not a device question".

Nine have no device to ask. The asset compilers and
MaterialSpecularMicrofacetEnvironmentGGXLUT.Generate run at build time, and two backend sites pick a
profile before GraphicsDeviceFeatures exists. No query can replace these.

Seven are device questions with no capability yet. SamplerState.Direct3D11.cs:87 caps anisotropy
on 9.1, Texture.Direct3D11.cs:1015 asks for the standard multisample quality pattern, and the three
CheckMipLevels workarounds ask about a Direct3D 9 driver limit. These want the query. They keep the
comparison because the capability set has no term for what they ask.

Two read a profile that is not the right source, GraphicsCompositorHelper.cs:60 and
LightShaderGroupDynamic.cs:94. The second is below.

Two further sites read a profile without comparing it: EffectSystem.cs:145 is the content target,
and GraphicsDeviceManager.cs:624 compares one request to another. Both are right as they are.

Fixed

  • Vulkan rejected 32-bit index buffers when a project targeted 9.x, though VK_INDEX_TYPE_UINT32
    is core Vulkan. CurrentProfile is a device fact only on Direct3D 11; Vulkan echoes the request.
  • LightShafts dereferenced a null ShadowMapRenderer at draw time.
    ForwardLightingRenderFeature treats it as optional, and GraphicsCompositorHelper builds the
    feature without one below profile 10.0. It now throws where it initialises.
  • CheckMipLevels was inverted on Direct3D 11. The workaround targets block-compressed textures,
    which are never depth-stencil, so the condition never fired. Direct3D 12 and Vulkan have the !.
  • Vulkan reported no format support for anything. FormatSupport now comes from
    vkGetPhysicalDeviceFormatProperties. MultisampleCountMax deliberately stays None, because
    ForwardRenderer and Texture.InitializeFrom read it raw and it is what keeps the unimplemented
    multisampling path unreachable.
  • Nothing logged the graphics device. GraphicsDeviceFeatures.ToString() had no caller anywhere.
    GraphicsDevice.Recreate now logs it, on both the create and the reset path, and ToString() gained
    the flags it omitted — every one that makes a renderer quietly do something else.

Found, not fixed

  • The GGX LUT is keyed on a profile when the question is texture format support.
  • Texture.Vulkan.cs:670 truncates compressed mip chains on Vulkan when a project targets 9.x.
  • LightShaderGroupDynamic.cs:39 ignores ShaderProfile, so its permutation count can disagree with
    what EffectSystem compiles.

What it does not do

Nothing touches GraphicsProfile, the asset pipeline or serialized settings. The profile stays the
build-time content target and the device-creation target, which no capability query can replace. Only
one new capability is added. The seven sites that want the query each need a different one, and each
of those deserves a considered design rather than a term bolted on here.

Related Issue

Follows the discussion on #3302 about the feature level system and moving toward capabilities.

Types of changes

  • Docs change / refactoring / dependency upgrade
  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

Checklist

  • My change requires a change to the documentation.
  • I have added tests to cover my changes.
  • All new and existing tests passed.
  • I have built and run the editor to try this change out.

Validation status

  • Build: clean under StrideGraphicsApi=Direct3D11, =Direct3D12 and =Vulkan. Not =Null
    that backend does not compile on master today, with partial-method signature drift in
    Null/CommandList.Null.cs and Null/Texture.Null.cs. The GraphicsBackend.Null.cs partial this PR
    adds is therefore unverified, and an earlier claim here that Null built clean was an incremental
    no-op on my part.
  • Tests: three facts in TestGraphicsCapability, all passing, no device needed. They guard how the
    set grows: a GraphicsCapabilityKind that nothing answers for, two capabilities on one kind, and a
    kind with no switch arm, which throws today because the switch has a default arm the compiler
    accepts. They do not validate this change.
  • Run: the end-user sample suite passes on every leg — Direct3D11, Direct3D12 and Vulkan on Windows,
    Vulkan on Linux — at 17 of 17 each, plus both packaging jobs. No frame drifted past its LPIPS
    baseline and none deferred to the vision gate.
  • Editor: built and run on Direct3D11. GameStudio opens a project, and a game launched from it runs
    and renders.

The device report makes it clear what that run did and did not cover. Every sample logs:

Graphics device: Microsoft Basic Render Driver (Direct3D11)
Requested: Level_9_3, Current: Level_9_3, HasComputeShaders: False, ... HasDepthAsSRV: False

The samples render at profile 9_3, so they do exercise the paths this PR changes most: CheckMipLevels
now fires where the missing ! kept it unreachable, the depth-SRV flags read False, and
HasIndex32Bits is False exactly as the old CurrentProfile <= Level_9_3 was. Gold images still
match, so none of those changed what is drawn.

Two things the suite does not reach: the VoxelRenderer behaviour change, because no fixture uses
voxel GI, and multisampling, because every fixture renders with MSAALevel: None.

A game run from the editor on real hardware covers the other side of every one of those flags:

Graphics device: Intel(R) Arc(TM) 140V GPU (16GB) (Direct3D11)
Requested: Level_10_0, Current: Level_10_0, HasComputeShaders: True, ... HasDepthAsSRV: True,
HasDepthAsReadOnlyRT: False, ... HasIndex32Bits: True

sasvdw and others added 12 commits August 23, 2026 12:50
A capability query answers only "can I do this now", so each renderer learns of
a shortfall when it runs. The codebase has 17 such checks and 5 different
outcomes. A declaration carries the same answer earlier, so something else can
report every shortfall together.

The declarer evaluates its own condition and records the outcome. A list of
predicates cannot express a need that depends on configuration, or a clamp.

Severity separates the two cases. Preferred means the declarer has a fallback.
Required means it cannot run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The compositor subscribes to RenderContext.RendererInitialized, which already
fires for every renderer and render feature. Nothing has to walk the graph.

The report goes out at the end of the first DrawCore. That is the earliest
complete point: PreDrawCoreInternal also calls EnsureContext, so an image
effect initializes when it first draws, not when it collects.

The report also logs the adapter and the feature set. Neither was logged
before. GraphicsDeviceFeatures.ToString had no caller at all.

Unmet Required requirements throw together and name each renderer. Before
this, the first renderer to reach its own check decided how the game failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two call sites, one of each severity.

ForwardRenderer already resolved its multisample count against the device. It
now reports that decision. Only one of its three downgrade paths warned, so a
project asking for 4x could silently get none.

VoxelRenderer threw from Collect and returned silently from Draw, on the same
condition. Both now read one flag, so the two paths agree.

Its declaration keeps the profile comparison, so no device changes behaviour.
The reason string says the comparison stands in for a query we do not have.

VoxelRenderer is a plain data object, not a RendererCoreBase, so its owner has
to pass the collector down. That gap is left visible on purpose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Require and Prefer took a free-form string and a caller-computed bool. Nothing
central owned either, so neither call site named a real capability. One passed a
stringified sample count. The other passed a profile comparison, which is the
thing this design exists to replace.

GraphicsCapability now owns identity and reads the answer from
GraphicsDeviceFeatures. A renderer states what it needs, not whether it has it.
Two kinds exist, both backed by data the engine already queries: a device
feature flag, and multisampling of one pixel format at one sample count. A tier
fits the same base once a backend has tier data.

Kind and Name stay separate. A backend implements a kind or it does not, while
the device answers per instance.

GraphicsDeviceFeatures gained IsImplementedByBackend. The Vulkan backend
declares multisampling unimplemented, because Texture.Vulkan.cs and
PipelineState.Vulkan.cs hardcode VkSampleCountFlags.Count1 and no
MultisampleCount conversion exists. The outcome is three-valued as a result, so
the report can name the backend instead of blaming the hardware.

ForwardRenderer declares multisampling for both formats it renders. It no longer
computes the answer, so the iOS override can no longer make a device capability
report unmet for a reason that is not the device.

VoxelRenderer declared a profile comparison that stood in for two questions.
Compute is a runtime capability, so it declares that. Shader model 5 is a
content target, already enforced when the effect compiler picks 5_0 or 4_0, so
it does not belong in a device query.

That changes behaviour. HasComputeShaders is queried only on Direct3D11 and is
hardcoded true elsewhere, so voxelization now runs where the profile comparison
refused. It is also coarser than voxelization needs, and the capability set has
no way to say shader model 5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The collector did not earn its 320 lines. Of what it claimed:

Earlier reporting was false. Declaration happened in InitializeCore, and a
query there happens at the same moment. Earliness comes from asking at
initialization instead of at the point of use, which a renderer does alone.

Severity collapses. With a query, Required is a throw and Preferred is a
degrade. Attribution was already there; ForwardRenderer names itself in its
own warning.

Only aggregation was real, and that is diagnostics rather than architecture. It
goes, and a compositor that needs three unavailable things now reports them one
at a time.

What stays is the vocabulary, which is what makes the check honest.

GraphicsBackend.Implements replaces the instance member on
GraphicsDeviceFeatures. Backend support is static and needs no device, so build
time can read it. A game's platform head project sets StridePlatform, and
Stride.Platform.props derives the graphics API from it, so a build targets one
backend and answers only for that one. Each backend supplies its own partial,
and a missing one fails the build.

GraphicsDeviceFeatures keeps the device half in Provides and combines both in
Supports. The backend is asked first. What a device provides does not matter
when the backend cannot drive it, and the two have different remedies: another
device fixes one, and only a change to Stride fixes the other.

ForwardRenderer keeps its clamp and reads Supports to say why it fell back. It
now names the backend rather than reporting "not supported", which blamed the
hardware for a gap in Stride.

VoxelRenderer asks for compute shaders in Collect and stores the answer, so
Draw agrees with it.

The compositor loses the collector wiring and keeps one line: it logs the
adapter and the device features once. Nothing logged either before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The report sat in GraphicsCompositor.InitializeCore, which is the wrong owner.
GameBase.InitializeBeforeRun creates the device, then loads content, and the
compositor is content. It is also a RendererCoreBase, so it does not initialize
until its first draw.

So a device fact was reported by a content object, one frame late, once per
compositor rather than once per device. GraphicsDevice.Recreate rebuilds
features on a reset and nothing re-initializes the compositor, so the report
also went stale and never corrected itself.

Per compositor is the wrong count. An editor runs roughly 2N + 1 compositors
plus one per thumbnail key, across N + 2 devices, because the preview and
thumbnail services each build their own device and request different profiles.
The compositor can also be null: SceneSystem calls Draw on it with ?., and the
editor, the preview game and the thumbnail generator each leave it null at
times.

GraphicsDevice.Recreate is the single point where features are built, on both
the create and the reset path, so the report belongs there.

The partial class had no shared logger, and the two Direct3D partials each
declared their own. The declaration moves to GraphicsDevice.cs, where one
serves all four backends.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three defects in the previous commits of this branch.

VoxelRenderer replaced a throw with a bare return, so a user who added the
voxel renderer and a volume got no lighting and no reason. It now warns once.
Collect runs every frame, so the report is latched, the way SceneCameraRenderer
latches its missing-camera warning.

ForwardRenderer computed its reason from the color format alone, but the clamp
can come from the depth format or from the depth shader-resource flag, so it
could name a cause it had not checked. It now names the constraint that bit.
The iOS clamp also ran after the warning, which made that path silent, so it
moves above it.

GraphicsDeviceFeatures.ToString printed RequestedProfile, HasComputeShaders,
HasDoublePrecision, HasMultiThreadingConcurrentResources and
HasDriverCommandLists. It printed none of CurrentProfile, HasDepthAsSRV,
HasDepthAsReadOnlyRT, HasMultiSampleDepthAsSRV, HasResourceRenaming or HasSRgb,
which is every flag that makes a renderer quietly do something else. The device
report added in the previous commit could therefore not explain a single
degrade it was meant to explain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Neither is about capabilities. Both turned up while reading how renderers react
when one is missing, which is the argument for doing this work.

LightShafts read forwardLightingFeature.ShadowMapRenderer without a guard and
dereferenced it while drawing. ForwardLightingRenderFeature treats that property
as optional and null-checks it twice, and GraphicsCompositorHelper builds the
feature without one below profile 10.0. The result was a NullReferenceException
in the draw loop. It now throws where it initializes, beside the two throws that
already check for a missing mesh render feature and a missing forward lighting
feature.

CheckMipLevels on Direct3D 11 tested HasFlag(TextureFlags.DepthStencil) where
Direct3D 12 and Vulkan test the negation. The documented purpose is to work
around DXT images smaller than 4x4 on Direct3D 9 hardware, and a depth-stencil
texture is never block compressed, so the condition never fired and the
workaround was dead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Audited all twenty profile comparisons. Seven are device questions and change
here. The other thirteen are not.

A new Index32Bits capability replaces the comparison in GeometricPrimitive and
PrimitiveProceduralModelBase. Direct3D 11 defines the flag as the condition it
replaces, so that backend is unchanged. It does fix Vulkan, where CurrentProfile
is the request echoed back, so a project asking for 9.x could not build a
32-bit-index primitive.

Three backend sites now read the flag they were re-deriving.

The rest stay. EffectSystem is the content target itself. GraphicsDeviceManager
compares request to request. The Stride.Assets sites have no device.
MaterialSpecularMicrofacetEnvironmentGGXLUT.Generate runs at build time too,
where there is none to ask.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The per-format table was filled with None. Only MultisampleCountMax is
load-bearing there: ForwardRenderer and Texture.InitializeFrom read it raw, and
None is what keeps this backend's unimplemented multisampling unreachable.
FormatSupport has no reader at all, so filling it from
vkGetPhysicalDeviceFormatProperties changes no behaviour and unblocks the
questions that were waiting on it.

MultisampleCountMax stays None on purpose. Only flags the query answers
precisely are mapped; Texture1D, Texture3D and TextureCube need the image format
query instead. TryConvertPixelFormat is new because ConvertPixelFormat throws on
formats it does not cover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DeviceFeatureCapability.IsProvidedByDevice ends in a default arm that
throws, so the compiler cannot report a kind that nothing answers for.

Three facts, no device needed: every kind has a capability, no two
capabilities share a kind, and every flag capability reaches a switch arm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
HasIndex32Bits decides between 16-bit and 32-bit index buffers with no
other signal, and ToString did not report it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
HasDepthAsSRV = CurrentProfile >= Level_10_0;
HasDepthAsReadOnlyRT = CurrentProfile >= Level_11_0;
HasMultiSampleDepthAsSRV = CurrentProfile >= Level_11_0;
HasIndex32Bits = CurrentProfile > Level_9_3;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not important, as DX9 is already removed as well as OpenGL, but...
shouldn't this be a >=? I remember using 32-bit indices on Direct3D 9.0c

Comment on lines +294 to +306
public static bool TryConvertPixelFormat(PixelFormat inputFormat, out VkFormat format)
{
try
{
ConvertPixelFormat(inputFormat, out format, out _, out _);
return true;
}
catch (InvalidOperationException)
{
format = VkFormat.Undefined;
return false;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would do this the other way around, i.e., having the TryConvertPixelFormat as the "cheap" method that determines the conversion and then doing ConvertPixelFormat as the stub that calls TryConvertPixelFormat and throwns an exception when unsuccesful.

That way the Try... variant is the cheap one. And I also would document the exception.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with this. Been looking at it all for too long to realise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in f442c37.

TryConvertPixelFormat now holds the switch and returns false for a format with no Vulkan equivalent; ConvertPixelFormat calls it and throws, with an <exception> tag on it as you suggested.

Worth noting the cost was not only stylistic: GraphicsDeviceFeatures.Vulkan walks all 256 PixelFormat values at device creation, and most have no Vulkan equivalent, so the old wrapper threw and caught an InvalidOperationException for nearly every one of them.

All four existing signatures are unchanged, so every call site compiles as before — the only addition is a four-argument TryConvertPixelFormat overload that carries the conversion, with the two-argument one delegating to it. Verified by building the Vulkan variant specifically (-p:StrideGraphicsApis=Vulkan), since the file is behind STRIDE_GRAPHICS_API_VULKAN and a default Direct3D11 build does not compile it.

Still looking at your other two comments.

Comment on lines +10 to +12
/// A backend implements a kind or it does not. The device answers per capability instance, because
/// multisampling support depends on the pixel format and the sample count.
/// </remarks>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The remark about "multisampling support" is too specific for an enum GraphicsCapabilityKind that describes capabilities in the general sense.

I mean, explain that "backends" declare they support specific capabilities, but the user device can or cannot support some of them.

@Ethereal77

Copy link
Copy Markdown
Contributor

As I already said, I'm not opposed to moving from a feature level to a capability-based system if done well. I think however this may be a big breaking change. Right now we are at -beta5, I don't know if this is "beta" in the classic sense (bug fixing, polishing, etc.) and this would make more sense in an "alpha" to release on the next version of Stride.

Anyway, I have a few worries:

  • Up until now, "backend" have not been an "official" term in Stride. We referred to graphics APIs (even though we meant the backend that used that API). If it reads easier for users, having a GraphicsBackend seems to be a good idea. But better decide what to do, as having several names for the same thing may confuse people.

  • Right now, GraphicsPlatform is the way of identifying the platform that is being run. It can be queried in the GraphicsDevice's static Platform property. It's an enum.

  • Also, if GraphicsBackend were a static type that represents the currently executing graphics backend, I'd centralize some of the information that is now in other parts. For example, it could have a name, a GraphicsPlatform, etc.

  • What is the benefit of it being a full type instead of an enum (as it is right now) and having backend-dependent extensions on it declaring what is supported?

  • Another concern is that it seems to be a lot of overhead having capabilities being classes. It seems it inverts the control for no good reason. Let me explain:

    • I thought (and tried myself to keep) Features was as light as possible. A structure populated once that could be queried. It could be made lazy even to make it even lighter. But having the capabilities being classes that accept a GraphicsDeviceFeatures just to query it and answer indirectly seems off, and now it is not a light struct, but heap allocated classes.
    • This can become problematic specially for formats. For example, the multisample support capabilities seem to be thought for having one per format, and there could be hundreds of formats + multi-sample counts combinations.
    • For backend capabilities, the backend could answer the "supports this" questions on its own, without needing another type.

I like where this goes, but I feel it a bit over-abstracted for not the correct reasons. A direct way to query the backend, and a direct way for the device to answer the "supports" questions (that would also ask the backend) could be lighter while offering the same functionality. That's my honest opinion.

@xen2 What's your take on this?

@xen2

xen2 commented Aug 25, 2026

Copy link
Copy Markdown
Member

Yes, this new capability system will be for 4.5. I would like to take some time to make sure it's designed properly before moving forward.

Also I agree with several points from @Ethereal77
I think we will need a few iterations on the design.

I will focus on 4.4 release first, then I will come back on this topic, so please wait a few days/weeks.

@xen2 xen2 added this to the 4.5 milestone Aug 25, 2026
TryConvertPixelFormat called ConvertPixelFormat and caught the exception, so
the cheap method paid for the expensive one. GraphicsDeviceFeatures walks all
256 pixel formats at device creation, and most of them have no Vulkan format,
so it threw and caught an exception for nearly every one.

TryConvertPixelFormat now holds the conversion and gives false for a format
that does not convert. ConvertPixelFormat calls it and throws, and it now
documents the exception.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants