Skip to content

Shaders

Every shader in Lumina is written in Slang and compiled to SPIR-V. Engine sources live in Engine/Resources/Shaders, with shared code under Includes/ and material stage templates under MaterialShader/.

There is no HLSL or GLSL path, and no runtime GLSL compiler.

Shaders are not engine-only. Shaders::GetSearchRoots returns the ordered VFS directories that hold compilable .slang and that Slang resolves #include against:

  1. the engine tree,
  2. every enabled plugin’s /Shaders,
  3. the loaded project’s /Game/Shaders,
  4. anything a module registered through Shaders::RegisterSearchRoot.

Engine comes first so a plugin or game shipping a file of the same name can never shadow it. A shadowed name is reported, and the collider has to be requested by its full virtual path. Roots that do not exist on disk are skipped, which is why a packaged build (source stripped, only the compiled cache shipped) reports none.

Shaders::PrecompileNewRoots() compiles everything directly under each root not yet enumerated. It runs from the compiler’s Initialize, when only the engine tree and engine plugins are mounted, and again after a project loads, when /Game and the project’s own plugins appear. Roots are remembered, so the second pass only picks up what is new. RegisterSearchRoot is safe after startup: on-demand lookups see it immediately, and the next precompile batch covers it.

FSpirVShaderCompiler (Renderer/ShaderCompiler.cpp) implements IShaderCompiler:

bool CompilerShaderRaw (FString Source, const FShaderCompileOptions&, CompletedFunc);
bool CompileShaderPath (FString Path, const FShaderCompileOptions&, CompletedFunc);
bool CompileShaderPaths(TSpan<FString>, TSpan<FShaderCompileOptions>, CompletedFunc);
bool HasPendingRequests() const;
void Flush() const;

Compilation is asynchronous: the completion callback receives an FShaderHeader with the bytecode and reflection data.

FShaderCompileOptions:

FieldPurpose
bGenerateReflectionDataEmit reflection alongside the bytecode. Defaults on.
MacroDefinitionsPreprocessor defines. These are part of the cache key.
DebugNameUsed as the Slang source path, so crash dumps and Aftermath resolve to <DebugName>.slang:line rather than the default RawShader. Also the registered debug name.

Slang target settings: SPIR-V, column-major matrix layout, GENERATE_SPIRV_DIRECTLY, and GENERATE_WHOLE_PROGRAM.

Slang’s IGlobalSession is not thread safe: objects created from one may only be touched by a single thread at a time. Creating a session per shader is also expensive, because each one loads the Slang core module.

The compiler therefore keeps a pool of global sessions. A compile job acquires one, creates a per-compile Session from it, and releases it when done. Slang explicitly supports reusing one global session for many sessions, so the pool grows only to the number of concurrent compiles. A file system shim (FShaderFS) routes Slang’s includes through the engine VFS.

Compilation runs on the job pool, so a cold shader build saturates the machine.

  • Optimization is forced to SLANG_OPTIMIZATION_LEVEL_HIGH. The default (-O1) emitted SPIR-V that failed validation on buffer-device-address pointer locals.
  • Debug info level is build and vendor gated. It is raised to STANDARD for Nsight source-level debugging on non-AMD, non-Shipping builds, and left minimal otherwise because the extra debug info triggered AMD driver problems.

FShaderCache (Renderer/ShaderCache.h) stores compiled SPIR-V as .lsc files under /Intermediates/ShaderCache (a VFS path, editor-writable). The cooker bundles the cache into the .pak, so packaged builds never invoke Slang.

Two different hashes are in play, and confusing them is how a stale-bytecode bug gets misdiagnosed:

  • The file name is hash(shader virtual path + sorted defines), so one shader plus one define set always maps to the same .lsc.
  • The validity check is ComputeSourceSetHash, which walks the shader and every file it includes, recursively, mixing in the sorted defines and the cache version. A hit is only served when that hash matches what the .lsc recorded.

Because the include graph is part of the hash, editing a struct in Includes/ invalidates every shader that pulls it in, automatically. There is no version constant to remember for that case. kShaderCacheVersion (currently 1) exists for changes the source hash cannot see: the .lsc binary layout itself, or a compiler configuration change that alters output from identical source.

Misses are queued to the parallel compile swarm; hits are served inline.

FShaderLibrary (Renderer/ShaderLibrary.h) is the runtime registry, and it hands out weak generational handles, not pointers:

static const FShaderH CountCS = FShaderLibrary::Get("VisBufferMaterialCount.slang");
const FShaderEntry* Entry = FShaderLibrary::Resolve(CountCS);

FShaderH is a THandle<FShaderEntry> declared in its own header (ShaderHandle.h), so the many places that only need to store one (material assets, draw commands, the resolve cache, pipeline keys) do not pull in the library, the RHI, and the compiler with them.

The semantics that matter:

  • Get(NameOrNamePath, Defines) fetches by bare name ("TexturePaint.slang", resolved against the search roots) or by full virtual path ("/Game/Shaders/GameOfLife.slang"). Use the path when two roots ship the same file name. It compiles on demand if the startup batch has not delivered it yet.
  • Resolve(Handle) is the only legal way to dereference. It returns null once the entry has been freed, which is the signal that whatever cached the handle must re-resolve. Never dereference any other way.
  • Commit(Key, Type, Spirv) interns bytecode by content and returns a handle with one strong reference. Identical bytecode returns the same handle, which is what collapses material instances into one draw batch key and therefore one draw.
  • Reference counting is deliberately partial. Only owning CMaterial stages hold strong references. Caches hold weak handles and are not counted, because an entry is content-keyed and shared, so freeing it when one owner recompiles would break every other owner.
  • Release never frees inline. At zero the entry is queued, and FlushPendingReleases frees it at a frame boundary where no lookup is in flight. RHI::Core::BeginFrame calls it.
  • Generation starts at 0 (not compiled yet) and bumps on every recommit. IsValid() is Generation != 0, and PipelineHash() is (ID << 32) | Generation, so a recompile changes every pipeline key that names the shader and the new bytecode is picked up without invalidating unrelated pipelines.

Externally produced bytecode (graph-compiled materials, particle systems) is registered through the same library, so the same content produces the same entry.

GShaderLibrary and GShaderCompiler are the globals, both owned by FRenderManager.

PathContents
Shaders/*.slangStandalone passes: instance and meshlet culling, cluster build, light cull, depth pyramid, GTAO, VisBuffer classification, deferred lighting, bloom, SMAA, tone mapping, volumetric fog and clouds, aerial perspective, particles, terrain, water, text, ImGui, RmlUi, environment and IBL convolution.
Shaders/Includes/*.slangShared code.
Shaders/MaterialShader/*.slangThe stage templates a material graph compiles into.
Shaders/Particles/*.slangParticle simulation template.

Notable includes:

IncludeProvides
GlobalRHI.slang / GlobalRHIStorage.slangThe bindless access layer: heap indexing helpers, SAMPLER_* stock sampler constants, SampleTexture2DGrad.
SceneGlobals.slangThe per-view and per-scene constant layout.
Common.slangMath and utility helpers.
Culling.slangFrustum, cone, and Hi-Z occlusion tests.
MeshletGeometry.slangMeshlet decode, shared by every geometry pass’s mesh stage.
MeshletCullCore.slangThe per-meshlet cull body, shared by the cull dispatches.
AppendBuffer.slangThe GPU append protocol used by every producer that reserves into a bounded region.
VisBufferSurface.slangReconstructing a surface from a VisBuffer pixel, used by the material lane.
GBuffer.slangGBuffer encode and decode.
SurfaceShading.slangThe PBR shading model.
IBL.slang / IBLRuntime.slang / ReflectionProbe.slangImage-based lighting, bake side and runtime side, plus local probes.
ShadowSampling.slangCascade and cube shadow lookups.
Froxel.slang / Fog.slang / Sky.slangAtmosphere and volumetrics.
MomentOIT.slangMoment-based order-independent transparency.
Tonemap.slang, ParallaxOcclusion.slang, DistanceField.slang, Spline.slang, Wind.slangShared feature code.
Water.slang, TerrainCommon.slang, TerrainData.slang, DBuffer.slang, DecalCommon.slang, SMAA.slang, TextCommon.slang, RmlUiCommon.slang, ImGuiCommon.slang, UIMaterialGlobals.slangFeature-specific shared code.

SAMPLER_* in GlobalRHI.slang must stay in lockstep with EStockSampler in RHICore.h. The stock samplers are registered by index at startup and the enum value is the slot, so a mismatch silently samples with the wrong filter or address mode. New stock samplers go on the end of both.

  • Bindless everywhere. A shader never declares a descriptor binding for a texture. It receives a uint heap slot in a constant struct and indexes the global heap.
  • Buffers are pointers. Structured data arrives as a device address in the push constant block, dereferenced directly. There are no StructuredBuffer bindings.
  • One push-constant struct per pass, uploaded through RHI::Core::CopyTransient and passed as the GPUPtr DrawArgs argument to the draw or dispatch. Where the struct is mirrored in C++, it carries a static_assert on its size against the Slang side.
  • Reverse-Z: depth clears to 0, comparisons are greater-than. Any shader that reconstructs depth must account for it.
  • Column-major matrices, matching the Slang target configuration.
  • Entry points are named main; FShaderEntry::Source() hands that to the pipeline through FShaderSource::EntryPoint.

FSpecializationConstant carries an ID, a value, and a type (UInt8 through Float32, plus Boolean). They are passed at pipeline creation and are part of the pipeline key.

The renderer uses them to collapse permutations. The geometry pipeline takes seven, all UInt32:

IDSelects
1Debug view modes compiled in
2Decals
3GTAO
4VisBuffer masked
5Skinning mode: static, skinned, or dynamic
6Shadow mask
7Per-triangle cull mode in the mesh shader

Prefer a specialization constant over a macro define when the variants share almost all of their code, because macro defines create separate cache entries while specialization constants share one compile. Masked geometry is the case where both are used: VISBUFFER_MASKED_GEOM is a define, because the masked mesh shader genuinely needs different interpolants, and the resulting pipeline still takes constant 4 so the pixel side stays one shader.

A material graph compiles to a stage template in MaterialShader/. Which template depends on the material’s domain and the pass:

TemplateUsed by
MeshletMesh.slang, MeshletVisBuffer.slangGeometry. Mesh stage only; the vertex-emulation templates were removed when mesh shaders became a requirement. MeshletVisBuffer.slang compiles twice, opaque and masked.
VisBufferMaskedPixel.slangMasked materials during the VisBuffer pass.
DeferredMaterial.slangThe per-material compute lane that writes the GBuffer.
BasePixelPass.slangForward-shaded surfaces.
TerrainBaseVertexPass.slang / TerrainBasePixelPass.slangTerrain.
DecalVertexPass.slang / DecalPixelPass.slangDecals.
PostProcessPixelPass.slangPost-process materials.
UIPixelPass.slangUI material brushes.

The editor’s material compiler emits the graph body into the template and submits it as raw source with a stable DebugName, which is why a material error message points at a readable name. Changing a material triggers a recompile that commits new bytecode and bumps the entry’s generation, so the change is visible without a restart.

Pixel shader register pressure is the dominant GPU bottleneck in this renderer. When adding to SurfaceShading.slang or any material pixel template, check occupancy before and after. A few extra live values across the shading loop cost more than the arithmetic they save. In the editor, GetPipelineStatistics backs the material editor’s numbers, through VK_KHR_pipeline_executable_properties.

SymptomCause
Garbage output that a cache wipe fixesSomething the source-set hash cannot see changed: the .lsc layout or the compiler configuration. Bump kShaderCacheVersion.
Shader compiles but samples the wrong waySAMPLER_* constants out of sync with EStockSampler.
Crash or corruption compiling many shaders at onceSlang session reuse across threads. Go through the session pool.
SPIR-V validation failure on buffer pointersOptimization level dropped below HIGH.
A cached FShaderH stops working after a recompileExpected. Resolve returned null; re-resolve rather than holding the pointer.
A material change does not take effectThe recompile failed. Check IsValid() on the resolved entry and the compile log.
A plugin shader resolves to the engine’s fileName collision across search roots. Request it by full virtual path.
<DebugName>.slang:line shows as RawShaderThe compile options did not set DebugName.