Skip to content

#include resolution inconsistency: DXC's include handler vs. ProcessShaderIncludesImpl #797

Description

@hzqst

TL;DR

DiligentCore resolves shader #include "X" directives two different ways:

  • At compile time, DXC/FXC maintain include-stack context. Once an include file has been loaded
    as e.g. Lighting/LightingTypes.hlsli, a nested #include "LightingConfig.h" inside that
    file is resolved relative to the loaded include path, so the compiler can request
    Lighting/LightingConfig.h.
  • At scan time, DiligentCore's own ProcessShaderIncludesImpl — a hand-rolled text scanner
    used to compute the BY_CONTENT render-state-cache hash (and for hot-reload dependency
    tracking and archiver include enumeration) — passes the literal include string to the
    stream factory with no include-stack / parent-directory resolution, so the same nested
    include is looked up as bare LightingConfig.h.

For shaders with nested directory-relative includes (RTXPT's PathTracer/Lighting/… tree), DXC/FXC
compiles cleanly while the scanner throws Failed to load shader source file 'LightingConfig.h'.
This is a latent DiligentCore bug. RTXPT works around it by hashing the
cache BY_NAME (which never invokes the scanner). The proper fix is to make
ProcessShaderIncludesImpl resolve nested includes against their parent include file's path,
matching DXC/FXC include-stack behavior.


Symptom

With the render-state cache in RENDER_STATE_CACHE_FILE_HASH_MODE_BY_CONTENT (the default):

ERROR: Failed to create input stream for source file LightingConfig.h
ERROR: Failed to load shader source file 'LightingConfig.h'
[std::runtime_error / std::pair exceptions]
ERROR: Failed to process includes in file 'PathTracer/Lighting/LightsBaker.hlsl': Unknown error

The shader compiles fine (DXC/FXC resolve the nested include) — only the content-hash step,
which re-scans the source to enumerate includes, fails. In the current RTXPT chain,
LightsBaker.hlsl includes Lighting/LightingTypes.hlsli; then LightingTypes.hlsli includes
the sibling LightingConfig.h. DXC/FXC resolve the second include as Lighting/LightingConfig.h,
but the scanner tries bare LightingConfig.h against the flat search dirs
shaders;shaders\PathTracer, neither of which contains the file at that bare name.


The two resolution paths

DXC/FXC keep include-stack context for nested includes

Diligent builds a source string and passes it to DXC/FXC with an empty source name; the emitted
#line 1 "PathTracer/Lighting/LightsBaker.hlsl" marker is useful for diagnostics, but it is not
a reliable mechanism for resolving a top-level sibling include relative to ShaderCI.FilePath.

The RTXPT case that compiles is nested: LightsBaker.hlsl includes
Lighting/LightingTypes.hlsli, which is reachable from the shaders\PathTracer search dir. Once
the compiler has loaded that include file, a sibling include inside it,
#include "LightingConfig.h", is resolved relative to the loaded include path, so DXC/FXC ask
their include handlers for a path equivalent to Lighting/LightingConfig.h.

DxcIncludeHandlerImpl::LoadSource
(DXCompiler.cpp:185) and
D3DIncludeImpl::Open
(ShaderD3DBase.cpp:65) do not compute that parent directory themselves; they open the path
requested by the compiler. The DXC handler additionally strips a leading .\ if DXC provides one:

// DXCompiler.cpp:210-214
if (fileName.size() > 2 && fileName[0] == '.' && (fileName[1] == '\\' || fileName[1] == '/'))
    fileName.erase(0, 2);
m_pStreamFactory->CreateInputStream(fileName.c_str(), &pSourceStream);

shaders\PathTracer (search dir) + Lighting/LightingConfig.hfound. ✔

The scanner passes the literal include text verbatim

ProcessShaderIncludesImpl
(ShaderToolsCommon.cpp:428) is a text scanner. It first opens Lighting/LightingTypes.hlsli;
inside that file, FindIncludes extracts the raw string between the quotes
(LightingConfig.h) and, when it recurses, sets the child's path to that bare name,
discarding the parent include path Lighting/:

// ShaderToolsCommon.cpp:439-448
[&](const std::string& FilePath, size_t Start, size_t End)
{
    if (!Includes.insert(FilePath).second)
        return;

    ShaderCreateInfo IncludeCI{ShaderCI};
    IncludeCI.FilePath     = FilePath.c_str();   // "LightingConfig.h" — parent include path thrown away
    IncludeCI.Source       = nullptr;
    IncludeCI.SourceLength = 0;
    ProcessShaderIncludesImpl(IncludeCI, Includes, IncludeHandler);
}

ReadShaderSourceFile then calls CreateInputStream("LightingConfig.h"), which only sees the
factory's flat search dirs (shaders;shaders\PathTracer), none of which contain Lighting/, and
throws (ShaderToolsCommon.cpp:243-245). ✘


The crux

The parent include path is available — during the failing nested recursion,
ShaderCI.FilePath holds Lighting/LightingTypes.hlsli — but the scanner never combines it with
the nested include name. So:

Include resolution rule
DXC/FXC include-stack-aware: a nested include is resolved relative to the loaded include file path, then search paths
Diligent scanner literal include string only; no include-stack / parent-path context

They agree only when every nested include is reachable from a search dir by the exact spelling
used in the source (e.g. Tutorial26's flat assets). RTXPT uses nested directory-relative includes,
so DXC/FXC compile cleanly while the scanner — invoked only to compute the BY_CONTENT cache hash —
fails.

It is a genuine latent bug, not just a quirk: any project with directory-relative shader
includes hits it under content hashing, hot-reload dependency tracking
(ShaderReloadFactory/RenderStateCache::Reload), or the archiver's include enumeration.


Scope / impact

Affected whenever ProcessShaderIncludesImpl runs over shaders with directory-relative includes:

  • RENDER_STATE_CACHE_FILE_HASH_MODE_BY_CONTENT — content hashing (the failure above).
  • Hot reload — ProcessShaderIncludes builds the include→dependency set for Reload().
  • Archiver — include enumeration when packaging shader source.

Not affected: actual DXC/FXC compilation (resolves correctly), and BY_NAME hashing.


Proposed fix

Make ProcessShaderIncludesImpl resolve each nested include against the path of the currently
scanned include file
before recursing — matching DXC/FXC include-stack behavior. DiligentCore
already provides the needed helpers in BasicFileSystem.hpp (already included by
ShaderToolsCommon.cpp): FileSystem::GetPathComponents, FileSystem::IsPathAbsolute,
FileSystem::SimplifyPath, FileSystem::SlashSymbol.

Minimal version (only safe for parent-relative include trees)

In the FindIncludes recursion lambda (ShaderToolsCommon.cpp:439-448), a simple parent-directory
join fixes trees where includes are consistently parent-relative. It is not safe for mixed
search-dir-relative includes such as RTXPT's first Lighting/LightingTypes.hlsli include; use the
robust version below for the engine fix.

[&](const std::string& IncludeName, size_t /*Start*/, size_t /*End*/)
{
    // Resolve the include relative to the path of the currently scanned file.
    // Without this, a nested include like "LightingConfig.h" inside
    // Lighting/LightingTypes.hlsli loses the "Lighting/" parent path and
    // cannot be found via the flat search dirs.
    std::string ResolvedPath = IncludeName;
    if (ShaderCI.FilePath != nullptr && !FileSystem::IsPathAbsolute(IncludeName.c_str()))
    {
        std::string ParentDir;
        FileSystem::GetPathComponents(ShaderCI.FilePath, &ParentDir, nullptr);
        if (!ParentDir.empty())
        {
            ResolvedPath = FileSystem::SimplifyPath(
                (ParentDir + FileSystem::SlashSymbol + IncludeName).c_str(),
                FileSystem::SlashSymbol);
        }
    }

    if (!Includes.insert(ResolvedPath).second)
        return;

    ShaderCreateInfo IncludeCI{ShaderCI};
    IncludeCI.FilePath     = ResolvedPath.c_str();   // resolved, not bare -> next level's parent dir is correct
    IncludeCI.Source       = nullptr;
    IncludeCI.SourceLength = 0;
    ProcessShaderIncludesImpl(IncludeCI, Includes, IncludeHandler);
}

Because the recursion now stores the resolved path in IncludeCI.FilePath, deeper
parent-relative includes compose correctly: A/B/file.hlsl"C/inc.h"A/B/C/inc.h
"deep.h"A/B/C/deep.h.

Robust version (matches DXC/FXC include-stack lookup)

DXC/FXC's effective behavior is current-include-relative first, then include search paths. A
search-dir-relative include (e.g. RTXPT's first Lighting/LightingTypes.hlsli) should still
resolve under its original spelling, not under a blindly prepended parent directory. To match
this, probe the stream factory and fall back to the original include spelling when the
current-include-relative path can't be opened:

static std::string ResolveIncludePath(const ShaderCreateInfo& ShaderCI, const std::string& IncludeName)
{
    if (ShaderCI.FilePath == nullptr || FileSystem::IsPathAbsolute(IncludeName.c_str()))
        return IncludeName;

    std::string ParentDir;
    FileSystem::GetPathComponents(ShaderCI.FilePath, &ParentDir, nullptr);
    if (ParentDir.empty())
        return IncludeName;

    const std::string Relative = FileSystem::SimplifyPath(
        (ParentDir + FileSystem::SlashSymbol + IncludeName).c_str(), FileSystem::SlashSymbol);

    // Phase 1: current-include-relative. Phase 2 (fallback): search-dir/original spelling.
    if (ShaderCI.pShaderSourceStreamFactory != nullptr)
    {
        RefCntAutoPtr<IFileStream> pStream;
        ShaderCI.pShaderSourceStreamFactory->CreateInputStream(Relative.c_str(), &pStream);
        if (pStream)
            return Relative;
        return IncludeName; // let the existing error path report the original name if this also fails
    }
    return Relative;
}

Notes / risks

  • Hash stability: the values inserted into Includes change from bare names to resolved
    paths, so BY_CONTENT hashes shift once. This is a one-time cache invalidation, not a
    correctness problem.
  • Backward compatibility: flat-asset projects (e.g. Tutorial26) still resolve, because the
    robust version falls back to the original include spelling. The minimal version can break mixed
    trees where a nested file is first included by a search-dir-relative path.
  • Angle vs quote includes: the scanner does not distinguish <...> from "...". The robust
    (try-relative-then-bare) approach is a superset of correct behavior for both and is the safer
    choice.

Key references

What Location
Scanner that discards the parent dir (root cause) DiligentCore/Graphics/ShaderTools/src/ShaderToolsCommon.cpp:428-454
Scanner throws on unresolved include DiligentCore/Graphics/ShaderTools/src/ShaderToolsCommon.cpp:224-245
DXC handler — opens the include path requested by DXC and strips leading .\ DiligentCore/Graphics/ShaderTools/src/DXCompiler.cpp:185-237
FXC handler — opens the include path requested by D3DCompile DiligentCore/Graphics/GraphicsEngineD3DBase/src/ShaderD3DBase.cpp:57-96
BY_NAME hashing (never calls the scanner) DiligentCore/Graphics/GraphicsTools/src/RenderStateCacheImpl.cpp (HashShaderCIByFileName)
Path helpers for the fix DiligentCore/Platforms/Basic/interface/BasicFileSystem.hpp:171-197
RTXPT workaround (BY_NAME) DiligentSamples/Samples/RTXPT/src/RTXPTSample.cpp (cache create block)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions