Shader and Material System - limztudio/NWBLot GitHub Wiki

Shader and Material System

NWBLot shader and material handling is cook-driven. Slang source, include metadata, shader variants, and material interface bindings are resolved during cooking. Runtime graphics consumes cooked, versioned shader payloads (exact entry point plus SPIR-V) and typed material payloads.

Shader Source Layout

Authored shader files include:

  • .slang: shader source
  • .slangi: shared include source
  • .nwb: shader or include metadata
  • .surface: material surface hook source
  • .bxdf: material lighting BXDF source
  • .bind: typed material interface declarations

Shared engine shader assets live under impl/assets/graphics, including:

  • mesh runtime and binding headers
  • deferred lighting framework and composite shaders
  • material pixel-shader authoring helpers
  • AVBOIT shaders
  • CSG interval shaders
  • shadow inline RayQuery opaque trace, staged hardware transparent evaluation, and software traversal shaders
  • refractive caustic producer, geometry downsample, and resolve shaders
  • BVH build/sort shaders
  • skinning compute shaders
  • custom UI raster/output shaders (impl/assets/graphics/ui/: vs/ps plus output_vs/output_ps, with glyph SDF/coverage helpers)
  • shared CPU/GPU macro headers

Shader Metadata

Shader .nwb metadata describes the author's shader choices:

compute_shader asset;

asset.entry_point = "main";

The declaration selects the physical shader stage. Use the matching concrete type, such as pixel_shader, vertex_shader, mesh_shader, or a ray tracing shader type; Asset Types lists every supported declaration. Generic shader declarations and authored asset.stage fields are rejected.

Field Contract
entry_point Required exact entry-point spelling; the concrete asset declaration determines the physical stage.
include_roots, defines Optional include roots and explicit define value sets.
ray_query Optional integer 0 or 1, default 0. Set 1 when the shader requires RayQuery capability.
optimization_level Optional none, default, high, or maximal; omission uses default.
emit_mesh_compute_shadow Mesh stages only: optional integer 0 or 1, default 1, controlling compute-shadow generation. Rejected on other stages; the fixed engine shared mesh requires 1.

Shader identity and the paired .slang path come from the metadata location. The ordinary archive role follows the concrete physical stage; implicit defines and the fixed engine shared-mesh object vertex stage are derived during cooking. These internal values are not author fields. Auxiliary archive roles remain separate: mesh_compute is ComputeShader assets, while mesh_object_vertex is a VertexShader.

The cooker expands define combinations into shader variants and computes dependency/source checksums for cache and determinism. Define names must be ASCII identifiers; values cannot contain semicolons, NUL/CR/LF, or leading/trailing whitespace because variant signatures must round-trip exactly. Dependency checks use stable alias roots for the repository, generated cook cache, generated material bind includes, and generated CSG modules. Source checksums also include a fingerprint of the configured compiler executable content/version and shared arguments, capabilities, stage mappings, and optimization options, computed once per nonempty shader-volume append. Each cache record is one <16-hex-source-checksum>.spv under the cache/configuration directory, with no paired checksum sidecar.

Dependency discovery splices escaped line endings and masks comments before resolving literal includes. It rejects unplanned macro includes, #include_next, and #import; the generated CSG evaluator macro is explicitly declared by its preparation caller, which separately tracks the generated source. Language-level Slang import dependency discovery remains unimplemented; current first-party shaders use includes.

The engine owns the current SPIR-V target/profile and adds the explicit RayQuery capability when ray_query is 1. Authors do not maintain compiler versions, backend profile names, or fixed object-stage selectors. The field validator rejects target_profile, mesh_object_vertex, per-asset compiler selectors, and other unknown fields.

Fixed engine transport switches are selected in source. Hardware ray shaders define NWB_BINDLESS_TLAS before their includes and reject external predefinition; authored defines rejects this reserved name, even with a one-value list. Archive and caller keys omit that fixed switch: non-reflection hardware shaders use default, while reflection shaders retain NWB_REFLECTION_OPTICAL_TRANSPORT=0, NWB_REFLECTION_OPTICAL_TRANSPORT=1, or NWB_REFLECTION_OPTICAL_TRANSPORT=2 as meaningful transport variants. ray_query remains the semantic capability requirement.

Shader Compilation

Shader compilation happens in cook targets under NWB_COOK. The single direct SlangShaderCompiler module (impl/assets_shader/slang_compiler.h and impl/assets_shader/slang_compiler.cpp) exposes compiler fingerprinting and compilation, owning stage/profile/option mapping, argv construction, BOM overlays, process launch, and bytecode validation. shader_volume_writer.cpp calls SlangShaderCompiler::ComputeCompilerFingerprint and SlangShaderCompiler::CompileVariant directly. The separate ShaderSourceDependencies module (source_dependencies.h and source_dependencies.cpp) owns IncludeKind, SpliceSourceLines, MaskSourceComments, ExtractIncludeDirective, and ResolveIncludeFile; dependency planning and BOM overlays share these source operations. ShaderCook consumes an already-parsed metascript Document and owns metadata validation, define/variant combinations, dependencies, and checksums. There is no compiler interface, factory, injection path, compile forwarding method, or production file parser overload. All 14 physical stages remain part of the current compiler/archive contract. Compilation requests contain:

  • shader name
  • stage
  • entry point
  • variant name
  • macro definitions
  • include directories and resolved dependency files
  • source path
  • output path
  • optimization and RayQuery choices
  • BOM presence recorded during dependency checksum reads

Each compiler invocation owns a unique process/invocation directory for diagnostics, output, and any stripped BOM overlay. Inputs without a BOM compile directly; BOM overlays preserve nested relative includes and encode absolute-root bytes into safe directory components. Cleanup runs on success and failure. Fresh output and cache hits both require the complete bounded SPIR-V stream to contain the exact requested entry point for its physical stage. Validated compiler output is atomically promoted to the checksum-named cache record; malformed or wrong-stage cache records are rebuilt.

The compiler is invoked with warnings enabled as errors and with the option that preserves the authored entry-point identifier in SPIR-V. Required captured diagnostics must be readable before the compiler result can be accepted. A nonzero compiler exit or any emitted diagnostic prevents that variant from being cooked. interface IShader owns shared entry-point text and bytecode; final physical-stage types use one internally versioned payload layout and shared serialization. The concrete asset validates the complete bounded SPIR-V stream and exact entry point for its static constexpr s_Stage before publishing state, preserving prior storage on failure. Validation consumes unaligned byte views directly. Runtime assets do not own source strings, include roots, define combinations, or compiler state, and retain no per-object stage field. Backend shader-module validation remains at the selected API boundary.

Shared CPU/GPU graphics ABI constants remain in macro-only headers under impl/assets/graphics, with common C++/Slang descriptor, AVBOIT and renderer values. The neutral nwb_shader_bytecode target owns core/graphics/spirv_entry_point.h and .cpp and declares a public nwb_common dependency, alongside the standard internal target defaults. Shader asset runtime/cook targets use it for admission, and nwb_graphics_backend uses the same parser for module validation. Payload encoding, concrete types and codecs remain in impl/assets_shader; provider-specific headers remain behind graphics selection.

Shader Archive

The pipeline emits a shader archive index. At runtime, the loader reads archive records and installs a shader path resolver callback in ProjectRuntimeContext.

Renderer systems resolve shader assets by:

  • shader name
  • variant name
  • stage name

The resolver returns a cooked virtual path, which is then loaded through AssetManager with the concrete shader type and its registered codec. ShaderAssetLoader::Load<ComputeShader>(...) and other typed calls derive the physical stage; LoadForStage(...) handles genuinely dynamic stage selection. An explicit archive role affects lookup without changing the concrete asset's physical stage. Known references use types such as AssetRef<PixelShader>; heterogeneous material stage storage uses AssetRef<IShader>. Shared storage and direct getters avoid extra allocation layers, bytecode copies, and per-frame virtual stage dispatch.

Surface And BXDF Authoring

Material authoring splits surface description from lighting:

  • .surface defines nwbMaterialSurface(), the per-material hook that reads typed material data and returns base color, normal, render coverage, BXDF parameters, specular F0, perceptual roughness, optical absorption tint, and shader-side refraction values. The engine packs F0 and roughness into a dedicated G-buffer target for deferred lighting and reflection shading, and captures the same values for primary glass.
  • .bxdf defines NWB_DEFERRED_BXDF_FUNCTION(NwbBxdfSurface surface, int2 pixel), the lighting model for surfaces using that material.

Shader-side surface color, BXDF color, AVBOIT color, specular F0, perceptual roughness, shadow transmittance, caustic transmission, and GI albedo paths use half-precision values where the contract supports it. Color-bearing raster payloads, G-buffer base-color and packed-normal outputs, AVBOIT accumulation and deferred presentation output use half4 (custom UI raster color stays float4 — see impl/assets/graphics/ui/ps.slang); world positions and other geometry-sensitive interfaces remain float. Material .bind files can declare half, half2, half3, and half4 fields; generated shader accessors unpack those from the typed material storage. Hardware ray payloads may keep float lanes for packing stability, then narrow back to the shared half-precision hit contracts at the producer boundary. The same bounded-temporary rule extends across the engine shaders: meshlet cone-cull sine/cosine factors (mesh/culling.slangi), the skinned meshlet-bounds cone-cutoff cosine accumulator (skinned_mesh/meshlet_bounds_cs.slang), the software-shadow punctual sine half-angle (shadow/shadow_ray_setup.slangi), the surfel SH per-ray projection scale and the software-shadow coarse edge-threshold chain, the soft-shadow guided-regression B-spline weights and reproject-merge temporal factors, the reflection screen-trace confidence chain, the refraction IOR/cosine/Fresnel path (optics/dielectric.slangi), the transparent-sampling settled-history gate, and the combined-wavelet normalized visibility ratios all compute in half and widen only at float comparisons, accumulators, stores, or buffer/image ABI. Positions, depths, normals, ray extents, history counts, buffer layouts, and image stores stay on float.

For materials that declare surface, the cook generates a G-buffer pixel shader from:

  • the engine pixel-shader authoring header
  • the material's generated .bind interface include
  • the material's .surface hook

The generated pixel shader is compiled like any other shader entry. The material mesh stage is assigned to the shared engine mesh shader engine/graphics/mesh/shared_ms.

For deferred lighting, the cook resolves every material bxdf, assigns deterministic shading-model ids by unique BXDF source, and emits deferred/generated/bxdf_dispatch.slangi. The lighting shader includes that generated dispatch module and routes each pixel by the shading-model id packed into the G-buffer. A BXDF supplies project-owned BRDF and light-loop policy. Current shading harnesses request linear radiance through nwbBxdfRequiresLinearOutput() for both SDR and HDR10; scene exposure and display mapping happen once at final presentation. The engine deliberately provides no default BXDF, so an unknown id is intentionally visible.

The same NwbBxdfSurface contract is shared by deferred lighting and the transparent AVBOIT accumulate path. Opaque deferred lighting builds it from the G-buffer, while AVBOIT accumulate builds it directly from the material's .surface hook before dispatching the material BXDF. Both harnesses expose scene-light resolution, visibility/caustic transport, nwbBxdfPresentationIsHdr10(), and nwbBxdfRequiresLinearOutput(). The presentation query reports the surface mode; the linear-output query remains true for transport in either mode. AVBOIT passes a negative pixel sentinel to the BXDF dispatch because transparent fragments are not present in the opaque screen-space shadow target. Its authoring header also disables shadow-visibility and caustic-irradiance sampled-image declarations for that pass, returning the same fully-lit and black-caustic constants without binding unused default set slots.

The cook assigns a surfaceDispatchId by unique .surface source and emits shadow/generated/surface_dispatch.slangi. nwbShadowDispatchSurface returns the hit material's surface contract, including refraction IOR and absorption tint. Staged hardware transparent gather/evaluate/overflow-continuation and software GPU-BVH traversal use those optical values to integrate Fresnel transmission and Beer-Lambert absorption across geometric entry/exit crossings. The shared soft-shadow resolve combines their colored signal with opaque visibility. If the staged hardware path cannot run, software traversal supplies transparent transmittance. The hardware opaque fast pass accepts opaque blockers without reading the surface table; opaque occluders also terminate standalone software traversal.

For transparent materials authored with surface, the cook also generates per-material AVBOIT occupancy, extinction, and accumulate pixel shaders from:

  • the engine AVBOIT occupancy, extinction, and accumulate authoring headers
  • the material's generated .bind interface include
  • the material's .surface hook

The transparent draw binds these generated shaders so transparent alpha, extinction, and color come from the same surface and BXDF logic as the opaque path. Transparent or refractive materials require a project surface hook; explicit shaders are restricted to opaque, non-refractive materials. The cooked transparent material must contain all three AVBOIT pass references; opaque materials must contain none. The cook, material serializer, and runtime loader reject a partial set.

Material Metadata

Material asset metadata supports these fields:

  • interface
  • surface
  • bxdf
  • shaders
  • shader_variant
  • parameters
  • transparent
  • two_sided
  • refractive

interface names the material interface from a project-rooted .bind file. The cooked material stores the extensionless interface name.

surface names a project-rooted .surface hook. When surface is present and shaders is absent, the cook generates the material pixel shader and assigns the shared engine mesh shader.

bxdf names a project-rooted .bxdf lighting model. It is required at cook time and is baked into the material as a shading-model id.

shaders maps stages to project-rooted shader assets for explicit materials. A material declares exactly one of surface or shaders; explicit stages are permitted only when both transparent and refractive are zero.

shader_variant selects the cooked shader define combination.

parameters supplies block-scoped authored values for the typed material layout, including texture and sampler asset paths.

transparent selects the transparent renderer path.

two_sided affects material/raster behavior.

refractive is a material-level caster classification used by refractive shadow and caustic paths. It is separate from transparent; the material flag only classifies the object, while the .surface hook returns refractionIor, shadowAbsorptionTint, and renderCoverage.

transparent, two_sided, and refractive are required explicit 0/1 authoring fields. An opaque, single-sided, non-refractive material writes all three zero values. asset.alpha is not a material-metadata field. Author alpha as a typed material value and/or return it from the surface hook instead.

Material-authored policy assets use the project/ virtual root: interface names a project-rooted .bind file, surface names a project-rooted .surface, bxdf names a project-rooted .bxdf, and explicit shaders entries name project-rooted shader assets. Shared engine shaders can still be assigned by cook logic; the generated material path, for example, assigns engine/graphics/mesh/shared_ms as the mesh stage.

.bind Files

Material interfaces are described by .bind files. The parser normalizes declarations into:

  • structs
  • instances
  • fields
  • attributes
  • string-only attribute arguments

The parser clears output state between parses, enforces unique symbols, and does not require an authored material_bind asset; declaration.

Typed block classes:

  • material_constant
  • material_mutable

Material constant blocks represent immutable material identity/default data. Material mutable blocks represent default values that MaterialInstanceComponent may override.

Static Texture And Sampler References

The material-resource ABI permits texture2d and sampler fields only in a material_constant block. Resource fields are plain declarations with no attributes:

[material_constant]
struct NwbSurfaceMaterial {
    texture2d base_color_map;
    sampler base_color_sampler;
};
NwbSurfaceMaterial surface;

The material assigns typed assets through the same block and field names in asset.parameters:

asset.parameters = {
    "surface": {
        "base_color_map": "project/textures/pattern",
        "base_color_sampler": "engine/samplers/linear_clamp",
    },
};

Both values are virtual paths to typed assets. texture2d currently accepts a 2D Texture; sampler accepts a Sampler. Project and engine roots are both supported.

The cooker records the block/field identity, resource kind/source, asset-name hash, and four-byte constant-byte offset in the cooked material. In memory, each reference owns exactly one typed asset reference selected by its kind: AssetRef<Texture> for texture2d or AssetRef<Sampler> for sampler. Loading reconstructs that typed reference from the stable virtual-path identity; it never deserializes a GpuDescriptorHandle, because descriptor slots are device-lifetime values. Once the global heap is available, the renderer loads/caches the typed asset through AssetManager, resolves its heap slot, and patches a working copy of the constant typed bytes.

The patched slot words travel through the draw and trace typed-material uploads, so the same surface hook can sample them in normal rasterization, AVBOIT, CSG, transparent-shadow traversal, surfel GI, and caustics without a pipeline-local descriptor.

Generated Slang deliberately keeps opaque resource values out of the numeric block struct and loader. Instead it emits standalone Texture2D<float4> and SamplerState accessors that read the patched word and select the global sampled-image or sampler heap with non-uniform helpers.

Resource fields cannot appear in material_mutable and cannot be overridden by MaterialInstanceComponent. The texture runtime supports cube and volume assets, while the material texture2d field accepts only the 2D descriptor class.

The cook-time and runtime handoff for shaders, .bind files, and materials looks like this:

flowchart LR
    ShaderSource["Shader source<br/>.slang / .slangi"]
    ShaderMeta["Shader metadata<br/>.nwb"]
    BindSource["Interface declarations<br/>.bind"]
    SurfaceSource["Surface hook<br/>.surface"]
    BxdfSource["Deferred lighting model<br/>.bxdf"]
    MaterialMeta["Material metadata<br/>.nwb"]
    ShaderCook["Shader cook<br/>variants + SPIR-V"]
    BindCook["Bind cook<br/>typed layout + resource refs"]
    GeneratedPS["Generated material PS"]
    GeneratedOccupancyPS["Generated AVBOIT occupancy/extinction PS"]
    GeneratedAvboitPS["Generated AVBOIT accumulate PS"]
    BxdfDispatch["Generated BXDF dispatch"]
    ShadowDispatch["Generated surface dispatch"]
    MaterialCook["Material cook<br/>typed bytes + resource/shader refs"]
    Archive["Shader archive index"]
    MaterialAsset["Material asset"]
    Runtime["Runtime renderer"]
    Binding["Typed binding 6"]

    ShaderSource --> ShaderCook
    ShaderMeta --> ShaderCook --> Archive --> Runtime
    BindSource --> BindCook --> GeneratedPS --> ShaderCook
    SurfaceSource --> GeneratedPS
    BindCook --> GeneratedOccupancyPS --> ShaderCook
    SurfaceSource --> GeneratedOccupancyPS
    BindCook --> GeneratedAvboitPS --> ShaderCook
    SurfaceSource --> GeneratedAvboitPS
    BxdfSource --> BxdfDispatch
    SurfaceSource --> ShadowDispatch
    MaterialMeta --> MaterialCook
    BindCook --> MaterialCook
    GeneratedPS --> MaterialCook
    GeneratedAvboitPS --> MaterialCook
    BxdfDispatch --> ShaderCook
    ShadowDispatch --> ShaderCook
    MaterialCook --> MaterialAsset --> Runtime --> Binding
Loading

Typed Layout

The material bind cooker builds:

  • layout hash
  • typed layout block table
  • typed layout field table
  • packed typed block bytes
  • block lookup
  • parameter lookup

Numeric field families include:

  • bool, bool2, bool3, bool4
  • char, char2, char3, char4
  • uchar, uchar2, uchar3, uchar4
  • short, short2, short3, short4
  • ushort, ushort2, ushort3, ushort4
  • int, int2, int3, int4
  • uint, uint2, uint3, uint4
  • half, half2, half3, half4
  • float, float2, float3, float4

The static-resource slice adds texture2d and sampler as distinct material-constant field kinds, not numeric field families. Each occupies one initially-zero, four-byte slot word in the constant payload; its cooked typed-asset reference determines how the renderer patches that word. Resource kind and placement participate in layout validation so a numeric uint cannot masquerade as a resource field.

Half fields are packed as 16-bit values. Typed blocks are padded to the material word size used by the renderer.

Runtime Material Binding

Material and shader parameter binding is typed-only at binding 6.

The renderer requires:

  • generated typed .bind helpers
  • NWB_MATERIAL_TYPED_BINDING == 1 for shaders that include a generated material bind
  • material or shader interface-name match
  • stable authored instance identifiers

In the generated material path, the pixel shader reads typed material data through the .surface hook and therefore carries the typed material bind. The shared mesh shader is generic and interface-free.

Generated typed accessors at binding 6 are the renderer's material-binding path.

Material Instances

MaterialInstanceComponent stores mutable overrides:

  • material interface name
  • override vector
  • revision

Each override records:

  • parameter name
  • block name
  • field name
  • field type
  • packed value

half4 mutable overrides are supported through the half-precision material value path and are packed into the same 16-bit typed layout as authored half4 defaults.

Static texture2d and sampler fields are intentionally absent from this override path. Their resource identities are part of the cooked material contract rather than per-instance mutable data.

The renderer prunes material instance mutable cache state during system update and gathers typed bytes for upload during material pass rendering.

Material draw and shadow gathering probe the typed-byte deduplication map with a borrowed byte span and its hash. A hit avoids a temporary owning copy and scratch allocation. A miss retains an owned key for the gather lifetime and copies it before growing the upload vector, so input that aliases that vector remains valid. Hash and length checks are followed by exact byte comparison to distinguish collisions.

Renderer Use

The renderer consumes material data in several places:

  • material pipeline creation
  • shader variant selection
  • generated surface pixel shader execution
  • deferred BXDF dispatch by shading-model id
  • surface dispatch by surfaceDispatchId for shadow optical evaluation
  • refractive caster classification for shadow and caustic work
  • material pass draw item gathering
  • typed material byte upload
  • static material texture/sampler resource resolution and typed-slot patching
  • opaque pass rendering
  • CSG receiver surface/cap rendering
  • transparent AVBOIT pass rendering, including per-material occupancy, extinction, and accumulate shading

Materials expose the mesh and pixel stages for the ECS renderer. If the device lacks native mesh shader support, eligible engine mesh programs use the persistent indexed route, while other draws derive compute-emulation resources from the authored mesh shader path. Neither route requires a different authored material.

⚠️ **GitHub.com Fallback** ⚠️