Skip to main content

MAIN_BINDLESS

Constant MAIN_BINDLESS 

Source
pub const MAIN_BINDLESS: &str = "// Bindless forward pass: vertex + fragment, single source for every backend.\n// Compile with -DPOOL_SIZE=<n> and -DMAX_PROBES=<n> (the bindless texture-pool\n// and reflection-probe array lengths the device binds).\n//\n// The shading body is shared; only the resource declarations differ per\n// binding model, selected by a define and bridged into the body through the\n// UPPER_CASE resource macros and the sampling accessors below:\n//\n//   default    - ParameterBlock layout: scene resources = Vulkan descriptor\n//                set 0 (bindings 0-16 in member order, combined image\n//                samplers), objects + texture pool = set 1. DXIL lowers each\n//                block to a register space.\n//   DXIL_SPLIT - same blocks with split texture + sampler pairs where the\n//                combined type\'s SampleCmp / GetDimensions lowerings are\n//                broken on DXIL.\n//   METAL_ABI  - the engine\'s Metal binding layout, frozen because\n//                world-authored Shader assets hand-write MSL against it:\n//                discrete buffers pinned by register() (b/t numbers ARE the\n//                Metal buffer indices), the texture-only argument buffer at\n//                buffer(7), and the engine sampler block at buffer(10).\n//   DXIL_ABI   - the engine\'s DirectX bindless root signature, frozen for the\n//                same reason (world Shader assets build a PSO against it, see\n//                directx/world_shaders.rs): every register pinned to the layout\n//                in directx/init/pipelines.rs, and the object id taken from the\n//                b0 root constant the indirect command writes rather than from\n//                an instance-id builtin.\n//\n// Layouts must match render_types.rs: GpuObjectData (176 B), GpuLight (64 B),\n// ClusterParams (128 B), SpotShadowData (80 B), AreaLightData (32 B).\n\n#ifndef POOL_SIZE\n#define POOL_SIZE 1024\n#endif\n#ifndef MAX_PROBES\n#define MAX_PROBES 8\n#endif\n\n// ---- Shared CPU-visible records ----\n\nstruct ViewUniforms\n{\n    float4x4 vp;\n    float4x4 view_mat;\n    float elapsed;\n    // 1.0 when an SSR / RT reflection composite owns the sharp specular this\n    // frame (fade the glossy-dielectric forward probe specular); 0.0 keeps it.\n    float reflections_enabled;\n    float cam_x; float cam_y; float cam_z;\n    float prefilter_mip_count;\n    // 1.0 while the unlit view mode is active: the surface returns its base\n    // color before lighting.\n    float shade_mode; float _ep1;\n};\n\n{OBJECT_COMMON}\n\nstruct DirLight   { float4 dir_i; float4 col; };\nstruct PointLight { float4 pos_r; float4 col_i; };\n\nstruct LightUniforms\n{\n    DirLight   dir[4];\n    PointLight pt[8];\n    int num_dir;\n    int num_pt;\n    // Indirect-ambient multiplier (PostProcessConfig.ambient_intensity); 1.0\n    // is a no-op.\n    float ambient_intensity;\n    // Valid entry count in the local-light buffer.\n    int num_local_lights;\n};\n\nstruct ShadowUniforms\n{\n    float4x4 light_vps[4];\n    float4 cascade_splits;\n    // Live cascade count (1..4); slots at or beyond it are unrendered.\n    uint active_cascades;\n};\n\n// GpuLight.kind discriminants (LIGHT_KIND_* in render_types.rs).\nstatic const uint LIGHT_KIND_SPOT = 1u;\nstatic const uint LIGHT_KIND_AREA = 2u;\n\n// Each (vec3, scalar) pair is spelled as one float4: MSL sizes a float3 at 16\n// bytes in a structured buffer as well as in a constant buffer, so a literal\n// transcription pushes every following field four bytes late on Metal alone.\nstruct GpuLight\n{\n    // xyz = world-space position, w = range.\n    float4 position_range;\n    // xyz = linear RGB, w = intensity.\n    float4 color_intensity;\n    // xyz = direction, w = the LIGHT_KIND_* discriminant\'s bits.\n    float4 direction_kind;\n    float  cos_inner;\n    float  cos_outer;\n    int    shadow_index;\n    // Index into the AreaLightData table for an area light, else -1.\n    int    data_index;\n};\n\nuint light_kind(GpuLight l) { return asuint(l.direction_kind.w); }\n\n// One shadowed spot\'s slice projection; indexed by GpuLight.shadow_index,\n// which doubles as the array layer.\nstruct SpotShadowData\n{\n    float4x4 light_vp;\n    float depth_bias;\n    float normal_bias;\n    float2 _pad;\n};\n\n// One rectangular area light\'s extent, indexed by GpuLight.data_index. The\n// edges are pre-scaled by the half-extents, so the corners are\n// centre +/- right +/- up.\nstruct AreaLightData\n{\n    // xyz = right edge, w = the two-sided flag\'s bits.\n    float4 right_two_sided;\n    // xyz = up edge, w unused.\n    float4 up_pad;\n};\n\nstruct ClusterParams\n{\n    float4x4 inv_view_proj;\n    // xyz = camera position, w = z_near. float4 pairs rather than float3 +\n    // scalar: MSL sizes a constant-buffer float3 at 16 bytes, so the packed\n    // 128-byte CPU layout only survives on every target without vec3 fields.\n    float4   cam_pos_znear;\n    // xyz = view forward, w = z_far.\n    float4   view_forward_zfar;\n    uint     grid_x;\n    uint     grid_y;\n    uint     grid_z;\n    uint     num_lights;\n    float    screen_w;\n    float    screen_h;\n    uint     use_clusters;\n    uint     _pad;\n};\n\n{PROBE_TYPES}\n\n// ---- Resource bindings ----\n\n#ifdef METAL_ABI\n\n// The engine\'s Metal argument buffer at buffer(7): texture handles only, in\n// the exact member order the host\'s argument encoder writes (tex_pool first,\n// then the shadow / IBL / SSAO / probe / LTC set). Samplers live in the\n// separate block below because indirect-command-buffer execution cannot see\n// encoder-bound sampler state.\nstruct BindlessTextures\n{\n    // Bindless texture pool: [albedo textures..] ++ [normal maps..]. The\n    // object record\'s albedo_index / normal_index address it directly.\n    Texture2D<float4> tex_pool[POOL_SIZE];\n    Texture2DArray<float4> shadow_map;\n    TextureCube<float4> irradiance_cube;\n    TextureCube<float4> prefilter_cube;\n    // Blurred SSAO occlusion (1x1 white when SSAO is disabled).\n    Texture2D<float4> ssao_tex;\n    // Local reflection-probe prefiltered radiance, one cube per probe; unused\n    // slices alias the sky prefilter.\n    TextureCube<float4> probe_cubes[MAX_PROBES];\n    // Spot shadow map array: one depth slice per shadow-casting spot light.\n    Texture2DArray<float4> spot_shadow_map;\n    // The two LTC lookup tables, sampled at (roughness, sqrt(1 - NdV)).\n    Texture2D<float4> ltc_matrix;\n    Texture2D<float4> ltc_magnitude;\n};\n\n// The engine\'s static samplers, mirrored from the host MTLSamplerDescriptors.\nstruct EngineSamplers\n{\n    SamplerState tex_sampler;\n    SamplerComparisonState shadow_sampler;\n    SamplerState cube_sampler;\n};\n\n// register() numbers pin the Metal buffer slots directly (b and t share the\n// index space there). The three pads reserve buffers 1-3 -- the vertex stream\n// rides buffer(1) and the layout keeps the legacy pass\'s gaps -- so the\n// argument buffer lands at buffer(7) and the sampler block at buffer(10), the\n// first free slots when their declarations are reached.\nConstantBuffer<ViewUniforms> view_cb : register(b0);\nConstantBuffer<float4> abi_pad1 : register(b1);\nConstantBuffer<float4> abi_pad2 : register(b2);\nConstantBuffer<float4> abi_pad3 : register(b3);\nConstantBuffer<LightUniforms> lights_cb : register(b4);\nConstantBuffer<ShadowUniforms> shadow_cb : register(b5);\nConstantBuffer<ProbeSet> probe_set_cb : register(b6);\nParameterBlock<BindlessTextures> tex;\n// Per-scene local lights (point + spot + area) for the forward pass.\nStructuredBuffer<GpuLight> local_lights_sb : register(t8);\nStructuredBuffer<GpuObjectData> objects_sb : register(t9);\nParameterBlock<EngineSamplers> samps;\nConstantBuffer<ClusterParams> cluster_cb : register(b11);\n// Per-cluster light-index lists the LightCull compute pass writes.\nStructuredBuffer<uint> cluster_list_sb : register(t12);\n// Spot shadow slice projections, indexed by GpuLight.shadow_index.\nStructuredBuffer<SpotShadowData> spot_shadows_sb : register(t13);\nStructuredBuffer<AreaLightData> area_lights_sb : register(t14);\n\n#define VIEW view_cb\n#define LIGHTS lights_cb\n#define SHADOW_UNI shadow_cb\n#define PROBE_SET probe_set_cb\n#define CLUSTER cluster_cb\n#define OBJECTS objects_sb\n#define LOCAL_LIGHTS local_lights_sb\n#define CLUSTER_LIST cluster_list_sb\n#define SPOT_SHADOWS spot_shadows_sb\n#define AREA_LIGHTS area_lights_sb\n\nfloat4 pool_sample(uint idx, float2 uv)\n{\n    return tex.tex_pool[idx].Sample(samps.tex_sampler, uv);\n}\nfloat shadow_map_cmp(float3 uv_layer, float ref)\n{\n    return tex.shadow_map.SampleCmp(samps.shadow_sampler, uv_layer, ref);\n}\nfloat spot_shadow_cmp(float3 uv_layer, float ref)\n{\n    return tex.spot_shadow_map.SampleCmp(samps.shadow_sampler, uv_layer, ref);\n}\nfloat2 shadow_map_size()\n{\n    uint w, h, e;\n    tex.shadow_map.GetDimensions(w, h, e);\n    return float2(float(w), float(h));\n}\nfloat2 spot_shadow_map_size()\n{\n    uint w, h, e;\n    tex.spot_shadow_map.GetDimensions(w, h, e);\n    return float2(float(w), float(h));\n}\nfloat ssao_sample(float2 uv)\n{\n    // The cube sampler doubles as the SSAO sampler: the occlusion texture\n    // wants linear + clamp-to-edge (a repeat sampler would wrap the border\n    // texels at the screen edges), which is exactly its filter state.\n    return tex.ssao_tex.Sample(samps.cube_sampler, uv).r;\n}\nfloat2 ssao_size()\n{\n    uint w, h;\n    tex.ssao_tex.GetDimensions(w, h);\n    return float2(float(w), float(h));\n}\nfloat3 irradiance_sample(float3 n)\n{\n    return tex.irradiance_cube.Sample(samps.cube_sampler, n).rgb;\n}\nfloat3 prefilter_sample_level0(float3 dir)\n{\n    return tex.prefilter_cube.SampleLevel(samps.cube_sampler, dir, 0.0).rgb;\n}\nfloat3 prefilter_sample_bias(float3 dir, float lod)\n{\n    return tex.prefilter_cube.SampleBias(samps.cube_sampler, dir, lod).rgb;\n}\nfloat3 probe_cube_sample_bias(uint i, float3 dir, float lod)\n{\n    return tex.probe_cubes[i].SampleBias(samps.cube_sampler, dir, lod).rgb;\n}\nfloat4 ltc_matrix_sample(float2 uv)\n{\n    return tex.ltc_matrix.SampleLevel(samps.cube_sampler, uv, 0.0);\n}\nfloat2 ltc_magnitude_sample(float2 uv)\n{\n    return tex.ltc_magnitude.SampleLevel(samps.cube_sampler, uv, 0.0).xy;\n}\n\n#elif defined(DXIL_ABI)\n\n// Every register below is pinned to the bindless main root signature in\n// directx/init/pipelines.rs. Root constant at b0, root CBVs at b1-b5, root SRVs\n// at t1/t2/t3/t15/t17, descriptor tables for the rest, and the unbounded\n// texture pool in space1.\nstruct ObjectId { uint value; };\n\nConstantBuffer<ObjectId> objid_cb : register(b0);\nConstantBuffer<ViewUniforms> view_cb : register(b1);\nConstantBuffer<LightUniforms> lights_cb : register(b2);\nConstantBuffer<ShadowUniforms> shadow_cb : register(b3);\nConstantBuffer<ProbeSet> probe_set_cb : register(b4);\nConstantBuffer<ClusterParams> cluster_cb : register(b5);\n\nTexture2DArray<float> shadow_map : register(t0);\n// Per-scene local lights (point + spot + area) for the forward pass.\nStructuredBuffer<GpuLight> local_lights_sb : register(t1);\n// Per-cluster light-index lists the LightCull compute pass writes.\nStructuredBuffer<uint> cluster_list_sb : register(t2);\nStructuredBuffer<GpuObjectData> objects_sb : register(t3);\n// Blurred SSAO occlusion (1x1 white when SSAO is disabled).\nTexture2D<float4> ssao_tex : register(t4);\nTextureCube<float4> irradiance_cube : register(t5);\nTextureCube<float4> prefilter_cube : register(t6);\n// Local reflection-probe prefiltered radiance; unbaked slots hold the sky\n// prefilter cube, so a sample at any index is valid.\nTextureCube<float4> probe_cubes[MAX_PROBES] : register(t7);\n// Spot shadow slice projections, indexed by GpuLight.shadow_index.\nStructuredBuffer<SpotShadowData> spot_shadows_sb : register(t15);\nTexture2DArray<float> spot_shadow_map : register(t16);\nStructuredBuffer<AreaLightData> area_lights_sb : register(t17);\n// The two LTC lookup tables, sampled at (roughness, sqrt(1 - NdV)).\nTexture2D<float4> ltc_matrix : register(t18);\nTexture2D<float4> ltc_magnitude : register(t19);\n// Bindless texture pool: [albedo textures..] ++ [normal maps..]. Unbounded so\n// the shader never over-declares the host\'s per-frame descriptor region.\nTexture2D<float4> tex_pool[] : register(t0, space1);\n\nSamplerComparisonState shadow_sampler : register(s0);\nSamplerState linear_sampler : register(s1);\nSamplerState cube_sampler : register(s2);\n\n#define VIEW view_cb\n#define LIGHTS lights_cb\n#define SHADOW_UNI shadow_cb\n#define PROBE_SET probe_set_cb\n#define CLUSTER cluster_cb\n#define OBJECTS objects_sb\n#define LOCAL_LIGHTS local_lights_sb\n#define CLUSTER_LIST cluster_list_sb\n#define SPOT_SHADOWS spot_shadows_sb\n#define AREA_LIGHTS area_lights_sb\n\nfloat4 pool_sample(uint idx, float2 uv)\n{\n    return tex_pool[NonUniformResourceIndex(idx)].Sample(linear_sampler, uv);\n}\nfloat shadow_map_cmp(float3 uv_layer, float ref)\n{\n    return shadow_map.SampleCmp(shadow_sampler, uv_layer, ref);\n}\nfloat spot_shadow_cmp(float3 uv_layer, float ref)\n{\n    return spot_shadow_map.SampleCmp(shadow_sampler, uv_layer, ref);\n}\nfloat2 shadow_map_size()\n{\n    uint w, h, e;\n    shadow_map.GetDimensions(w, h, e);\n    return float2(float(w), float(h));\n}\nfloat2 spot_shadow_map_size()\n{\n    uint w, h, e;\n    spot_shadow_map.GetDimensions(w, h, e);\n    return float2(float(w), float(h));\n}\nfloat ssao_sample(float2 uv)\n{\n    // Clamp-to-edge filtering: a repeat sampler would wrap the border texels\n    // at the screen edges. The cube sampler carries exactly that state.\n    return ssao_tex.Sample(cube_sampler, uv).r;\n}\nfloat2 ssao_size()\n{\n    uint w, h;\n    ssao_tex.GetDimensions(w, h);\n    return float2(float(w), float(h));\n}\nfloat3 irradiance_sample(float3 n)\n{\n    return irradiance_cube.Sample(cube_sampler, n).rgb;\n}\nfloat3 prefilter_sample_level0(float3 dir)\n{\n    return prefilter_cube.SampleLevel(cube_sampler, dir, 0.0).rgb;\n}\nfloat3 prefilter_sample_bias(float3 dir, float lod)\n{\n    return prefilter_cube.SampleBias(cube_sampler, dir, lod).rgb;\n}\nfloat3 probe_cube_sample_bias(uint i, float3 dir, float lod)\n{\n    return probe_cubes[i].SampleBias(cube_sampler, dir, lod).rgb;\n}\nfloat4 ltc_matrix_sample(float2 uv)\n{\n    return ltc_matrix.SampleLevel(cube_sampler, uv, 0.0);\n}\nfloat2 ltc_magnitude_sample(float2 uv)\n{\n    return ltc_magnitude.SampleLevel(cube_sampler, uv, 0.0).xy;\n}\n\n#else // ParameterBlock layout (Vulkan descriptor sets / DXIL register spaces)\n\nstruct SceneResources\n{\n    ConstantBuffer<ViewUniforms> view;\n    ConstantBuffer<LightUniforms> lights;\n    ConstantBuffer<ShadowUniforms> shadow_uni;\n    // The shadow arrays and the SSAO texture are combined texture-samplers on\n    // the targets whose descriptor model wants them fused (the engine\'s\n    // Vulkan layout uses COMBINED_IMAGE_SAMPLER; Metal lowers the pair\n    // itself), and split texture + sampler pairs on DXIL, where the combined\n    // type\'s SampleCmp / GetDimensions lowerings are broken.\n#ifdef DXIL_SPLIT\n    Texture2DArray<float4> shadow_map_t;\n    SamplerComparisonState shadow_map_s;\n#else\n    Sampler2DArray<float4> shadow_map;\n#endif\n    SamplerCube<float4> irradiance_cube;\n    SamplerCube<float4> prefilter_cube;\n    // Blurred SSAO occlusion (1x1 white when SSAO is disabled).\n#ifdef DXIL_SPLIT\n    Texture2D<float4> ssao_tex_t;\n    SamplerState ssao_tex_s;\n#else\n    Sampler2D<float4> ssao_tex;\n#endif\n    ConstantBuffer<ProbeSet> probe_set;\n    SamplerCube<float4> probe_cubes[MAX_PROBES];\n    // Per-scene local lights (point + spot + area) for the forward pass.\n    StructuredBuffer<GpuLight> local_lights;\n    ConstantBuffer<ClusterParams> cluster;\n    // Per-cluster light-index lists the LightCull compute pass writes.\n    StructuredBuffer<uint> cluster_list;\n    // Spot shadow depth array: one layer per shadow-casting spot.\n#ifdef DXIL_SPLIT\n    Texture2DArray<float4> spot_shadow_map_t;\n    SamplerComparisonState spot_shadow_map_s;\n#else\n    Sampler2DArray<float4> spot_shadow_map;\n#endif\n    StructuredBuffer<SpotShadowData> spot_shadows;\n    StructuredBuffer<AreaLightData> area_lights;\n    // The two LTC lookup tables, sampled at (roughness, sqrt(1 - NdV)).\n    Sampler2D<float4> ltc_matrix;\n    Sampler2D<float4> ltc_magnitude;\n};\n\nstruct ObjectResources\n{\n    StructuredBuffer<GpuObjectData> objects;\n    // Bindless texture pool: [albedo textures..] ++ [normal maps..]. The\n    // object record\'s albedo_index / normal_index address it directly.\n    Sampler2D<float4> tex_pool[POOL_SIZE];\n};\n\nParameterBlock<SceneResources> scene;\nParameterBlock<ObjectResources> objs;\n\n#define VIEW scene.view\n#define LIGHTS scene.lights\n#define SHADOW_UNI scene.shadow_uni\n#define PROBE_SET scene.probe_set\n#define CLUSTER scene.cluster\n#define OBJECTS objs.objects\n#define LOCAL_LIGHTS scene.local_lights\n#define CLUSTER_LIST scene.cluster_list\n#define SPOT_SHADOWS scene.spot_shadows\n#define AREA_LIGHTS scene.area_lights\n\n// NonUniformResourceIndex is required on the descriptor-indexing targets but\n// rejected by the Metal backend, where argument-buffer indexing needs no\n// annotation. The METAL_ABI block above never reaches this helper, but the\n// `metal` case keeps the default block target-portable too.\nuint nonuniform_index(uint i)\n{\n    __target_switch\n    {\n    case metal:\n        return i;\n    default:\n        return NonUniformResourceIndex(i);\n    }\n}\n\nfloat4 pool_sample(uint idx, float2 uv)\n{\n    return objs.tex_pool[nonuniform_index(idx)].Sample(uv);\n}\n\n// Sampling and size queries on the split-vs-combined resources above, so the\n// shader body stays identical across the two declaration forms.\n#ifdef DXIL_SPLIT\n\nfloat shadow_map_cmp(float3 uv_layer, float ref)\n{\n    return scene.shadow_map_t.SampleCmp(scene.shadow_map_s, uv_layer, ref);\n}\nfloat spot_shadow_cmp(float3 uv_layer, float ref)\n{\n    return scene.spot_shadow_map_t.SampleCmp(scene.spot_shadow_map_s, uv_layer, ref);\n}\nfloat2 shadow_map_size()\n{\n    uint w, h, e;\n    scene.shadow_map_t.GetDimensions(w, h, e);\n    return float2(float(w), float(h));\n}\nfloat2 spot_shadow_map_size()\n{\n    uint w, h, e;\n    scene.spot_shadow_map_t.GetDimensions(w, h, e);\n    return float2(float(w), float(h));\n}\nfloat ssao_sample(float2 uv)\n{\n    return scene.ssao_tex_t.Sample(scene.ssao_tex_s, uv).r;\n}\nfloat2 ssao_size()\n{\n    uint w, h;\n    scene.ssao_tex_t.GetDimensions(w, h);\n    return float2(float(w), float(h));\n}\n\n#else\n\nfloat shadow_map_cmp(float3 uv_layer, float ref)\n{\n    return scene.shadow_map.SampleCmp(uv_layer, ref);\n}\nfloat spot_shadow_cmp(float3 uv_layer, float ref)\n{\n    return scene.spot_shadow_map.SampleCmp(uv_layer, ref);\n}\n\n// The Metal target reads dimensions through inline MSL: the DXIL-style\n// GetDimensions overloads on combined types do not lower there. The resource\n// rides as a parameter so the inline form can reference it.\nfloat2 combined_array_size(Sampler2DArray<float4> s)\n{\n    __target_switch\n    {\n    case metal:\n        __intrinsic_asm \"float2((*$0).texture_0.get_width(), (*$0).texture_0.get_height())\";\n    default:\n        uint w, h, e;\n        s.GetDimensions(w, h, e);\n        return float2(float(w), float(h));\n    }\n}\nfloat2 combined_size(Sampler2D<float4> s)\n{\n    __target_switch\n    {\n    case metal:\n        __intrinsic_asm \"float2((*$0).texture_1.get_width(), (*$0).texture_1.get_height())\";\n    default:\n        uint w, h;\n        s.GetDimensions(w, h);\n        return float2(float(w), float(h));\n    }\n}\nfloat2 shadow_map_size()\n{\n    return combined_array_size(scene.shadow_map);\n}\nfloat2 spot_shadow_map_size()\n{\n    return combined_array_size(scene.spot_shadow_map);\n}\nfloat ssao_sample(float2 uv)\n{\n    return scene.ssao_tex.Sample(uv).r;\n}\nfloat2 ssao_size()\n{\n    return combined_size(scene.ssao_tex);\n}\n\n#endif\n\nfloat3 irradiance_sample(float3 n)\n{\n    return scene.irradiance_cube.Sample(n).rgb;\n}\nfloat3 prefilter_sample_level0(float3 dir)\n{\n    return scene.prefilter_cube.SampleLevel(dir, 0.0).rgb;\n}\nfloat3 prefilter_sample_bias(float3 dir, float lod)\n{\n    return scene.prefilter_cube.SampleBias(dir, lod).rgb;\n}\nfloat3 probe_cube_sample_bias(uint i, float3 dir, float lod)\n{\n    return scene.probe_cubes[i].SampleBias(dir, lod).rgb;\n}\nfloat4 ltc_matrix_sample(float2 uv)\n{\n    return scene.ltc_matrix.SampleLevel(uv, 0.0);\n}\nfloat2 ltc_magnitude_sample(float2 uv)\n{\n    return scene.ltc_magnitude.SampleLevel(uv, 0.0).xy;\n}\n\n#endif // binding model\n\n// ---- Constants ----\n\nstatic const float PI = 3.14159265359;\n\n// Per-cluster light-list stride: MAX_LIGHTS_PER_CLUSTER + 1 (slot 0 is the\n// count). Matches CLUSTER_LIGHT_LIST_STRIDE in render_types.rs.\nstatic const uint CLUSTER_LIGHT_LIST_STRIDE = 64u;\n\n// Surfaces rougher than this get no SSR / RT reflection; the forward fade\n// ramps in below it. Matches the resolve gloss gate (SSR_ROUGH_CUT /\n// RT_ROUGH_CUT).\nstatic const float REFLECTION_ROUGHNESS_CUT = 0.6;\n\n// Edge of the LTC lookup tables, and the scale / bias that map [0, 1] onto\n// texel centres. Must match LTC_LUT_SIZE in concinnity-render\'s ltc module.\nstatic const float LTC_LUT_SIZE  = 64.0;\nstatic const float LTC_LUT_SCALE = (LTC_LUT_SIZE - 1.0) / LTC_LUT_SIZE;\nstatic const float LTC_LUT_BIAS  = 0.5 / LTC_LUT_SIZE;\n\nstatic const float3 SKY_ZENITH  = float3(0.110, 0.322, 0.726);\nstatic const float3 SKY_HORIZON = float3(0.765, 0.863, 0.941);\n\n// ---- Stage interface ----\n\nstruct VertexIn\n{\n    [[vk::location(0)]] float3 pos     : POSITION;\n    [[vk::location(1)]] float3 normal  : NORMAL;\n    [[vk::location(2)]] float3 tangent : TANGENT;\n    [[vk::location(3)]] float3 color   : COLOR0;\n    [[vk::location(4)]] float2 uv      : TEXCOORD0;\n};\n\nstruct VertexOut\n{\n    float4 position : SV_Position;\n    [[vk::location(0)]] float3 world_pos  : TEXCOORD1;\n    [[vk::location(1)]] float3 normal     : TEXCOORD2;\n    [[vk::location(2)]] float3 tangent    : TEXCOORD3;\n    [[vk::location(3)]] float3 bitangent  : TEXCOORD4;\n    [[vk::location(4)]] float2 uv         : TEXCOORD5;\n    [[vk::location(5)]] float view_depth  : TEXCOORD6;\n    [[vk::location(6)]] float3 color      : TEXCOORD7;\n    // The object id is needed in the fragment stage too (the instance index is\n    // a vertex-only built-in), so it is forwarded as a flat varying.\n    [[vk::location(7)]] nointerpolation uint object_id : TEXCOORD8;\n};\n\n// ---- Vertex ----\n\n// Inverse-transpose of the model\'s upper 3x3 via the adjugate, so normals stay\n// perpendicular under non-uniform scale.\n[shader(\"vertex\")]\nVertexOut vertex_main_bindless(\n    VertexIn v\n#ifdef DXIL_ABI\n    // DirectX writes the object id into the b0 root constant ahead of each\n    // indirect draw, so no instance-id builtin is read: SV_StartInstanceLocation\n    // would raise the DXIL floor to shader model 6.8 for nothing.\n    )\n{\n    VertexOut o;\n    uint oid = objid_cb.value;\n#else\n    ,\n    uint instance_id : SV_InstanceID,\n    uint first_instance : SV_StartInstanceLocation)\n{\n    VertexOut o;\n    uint oid = object_instance_index(instance_id, first_instance);\n#endif\n    o.object_id = oid;\n    float4x4 model = OBJECTS[oid].model;\n\n    float4 world = mul(model, float4(v.pos, 1.0));\n    o.world_pos = world.xyz;\n\n    float3x3 nm = normal_matrix(model);\n    o.normal    = normalize(mul(nm, v.normal));\n    o.tangent   = normalize(mul(nm, v.tangent));\n    o.bitangent = cross(o.normal, o.tangent);\n\n    o.uv    = v.uv;\n    o.color = v.color;\n\n    o.view_depth = -mul(VIEW.view_mat, world).z;\n\n    o.position = mul(VIEW.vp, world);\n\n    // Skybox sentinel (blue channel 2.0): pin to the far plane.\n    if (v.color.b > 1.5)\n    {\n        o.position.z = o.position.w * (1.0 - 1e-6);\n    }\n    return o;\n}\n\n// ---- Fragment helpers ----\n\nfloat distribution_ggx(float3 N, float3 H, float roughness)\n{\n    float a  = roughness * roughness;\n    float a2 = a * a;\n    float NdH  = max(dot(N, H), 0.0);\n    float NdH2 = NdH * NdH;\n    float denom = NdH2 * (a2 - 1.0) + 1.0;\n    return a2 / (PI * denom * denom + 0.0001);\n}\n\nfloat geometry_schlick_ggx(float NdV, float roughness)\n{\n    float r = roughness + 1.0;\n    float k = (r * r) / 8.0;\n    return NdV / (NdV * (1.0 - k) + k);\n}\n\nfloat geometry_smith(float3 N, float3 V, float3 L, float roughness)\n{\n    float NdV = max(dot(N, V), 0.0);\n    float NdL = max(dot(N, L), 0.0);\n    return geometry_schlick_ggx(NdV, roughness) * geometry_schlick_ggx(NdL, roughness);\n}\n\nfloat3 fresnel_schlick(float cosTheta, float3 F0)\n{\n    return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);\n}\n\nfloat2 env_brdf_approx(float NdV, float rough)\n{\n    const float4 c0 = float4(-1.0, -0.0275, -0.572, 0.022);\n    const float4 c1 = float4( 1.0,  0.0425,  1.040, -0.040);\n    float4 r = rough * c0 + c1;\n    float a004 = min(r.x * r.x, exp2(-9.28 * NdV)) * r.x + r.y;\n    return float2(-1.04, 1.04) * a004 + r.zw;\n}\n\n// Decode a tangent-space normal map texel. Only X and Y are read; Z is\n// reconstructed from them, so a two-channel source (BC5) decodes the same as\n// an RGBA8 one and normal maps can ship as BC5 blocks.\nfloat3 decode_normal_map(float2 encoded)\n{\n    float2 nxy = encoded * 2.0 - 1.0;\n    return float3(nxy, sqrt(clamp(1.0 - dot(nxy, nxy), 0.0, 1.0)));\n}\n\n// Geometric specular antialiasing (Kaplanyan et al. 2016, as in Filament):\n// widen the NDF by the screen-space variance of the shading normal so an\n// undersampled high-frequency normal map at a distance does not alias into\n// specular fireflies. A no-op where the normal is smooth (close up), so the\n// surface detail is preserved.\nfloat specular_aa_roughness(float3 N, float perceptual_roughness)\n{\n    const float VARIANCE  = 0.25;\n    const float THRESHOLD = 0.18;\n    float3 dndx = ddx(N);\n    float3 dndy = ddy(N);\n    float variance = VARIANCE * (dot(dndx, dndx) + dot(dndy, dndy));\n    float alpha = perceptual_roughness * perceptual_roughness;\n    float kernel = min(2.0 * variance, THRESHOLD);\n    float filtered_alpha2 = clamp(alpha * alpha + kernel, 0.0, 1.0);\n    return sqrt(sqrt(filtered_alpha2));\n}\n\nfloat hash_rotation(float2 p)\n{\n    float h = frac(sin(dot(p, float2(12.9898, 78.233))) * 43758.5453);\n    return h * 6.2831853;\n}\n\n// Per-cascade shadow-bias growth. Metal bakes this factor into the shadow\n// pass\'s rasterizer depth bias (metal/draw/shadow.rs); every other backend\n// renders the cascades unbiased and applies it at the sample instead.\nfloat cascade_bias_scale(int cascade)\n{\n    __target_switch\n    {\n    case metal:\n        return 1.0;\n    default:\n        return 1.0 + float(cascade) * 2.0;\n    }\n}\n\n// 3x3 hash-rotated PCF of one spot shadow slice. Returns [0, 1] (1.0 fully\n// lit), and 1.0 outside the cone\'s light frustum so an unshadowed region is\n// never darkened. A smaller kernel than the cascade PCF: a spot slice covers\n// far less world area per texel.\nfloat sample_spot_shadow(int shadow_index, float3 world_pos, float3 normal, float2 screen_xy)\n{\n    SpotShadowData sd = SPOT_SHADOWS[shadow_index];\n    // Offsetting along the normal before projecting pushes the sample off\n    // surfaces near-parallel to the light, where depth slope causes acne.\n    float3 biased = world_pos + normal * sd.normal_bias;\n    float4 light_clip = mul(sd.light_vp, float4(biased, 1.0));\n    if (light_clip.w <= 0.0)\n    {\n        return 1.0;\n    }\n    float3 ndc = light_clip.xyz / light_clip.w;\n    // Flip Y to match the negative-height viewport the spot pass renders with,\n    // exactly as the cascade PCF does.\n    float2 uv = float2(ndc.x * 0.5 + 0.5, -ndc.y * 0.5 + 0.5);\n    if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0 || ndc.z < 0.0 || ndc.z > 1.0)\n    {\n        return 1.0;\n    }\n\n    float ref = ndc.z - sd.depth_bias;\n    float angle = hash_rotation(screen_xy);\n    float ca = cos(angle);\n    float sa = sin(angle);\n    float2 tex_size = 1.0 / spot_shadow_map_size();\n\n    float sum = 0.0;\n    const int RADIUS = 1; // 3x3\n    const float SAMPLES = float((2 * RADIUS + 1) * (2 * RADIUS + 1));\n    for (int dy = -RADIUS; dy <= RADIUS; dy++)\n    {\n        for (int dx = -RADIUS; dx <= RADIUS; dx++)\n        {\n            float2 off = float2(float(dx), float(dy));\n            float2 rot = float2(off.x * ca - off.y * sa, off.x * sa + off.y * ca);\n            sum += spot_shadow_cmp(\n                float3(uv + rot * tex_size, float(shadow_index)), ref);\n        }\n    }\n    return sum / SAMPLES;\n}\n\n// Clip a quad against the horizon plane z = 0, keeping the part above it.\n// Sutherland-Hodgman rather than the usual hardcoded 16-case table: a quad cut\n// by one plane yields at most 5 vertices, and the loop form cannot be got\n// wrong case by case. Mirrors clip_quad_to_horizon in concinnity-render\'s\n// ltc::polygon, which is unit-tested against brute-force integration.\nint clip_quad_to_horizon(float3 quad[4], out float3 clipped[5])\n{\n    clipped = { float3(0.0), float3(0.0), float3(0.0), float3(0.0), float3(0.0) };\n    int n = 0;\n    for (int i = 0; i < 4; i++)\n    {\n        float3 current  = quad[i];\n        float3 previous = quad[(i + 3) % 4];\n        bool current_in  = current.z > 0.0;\n        bool previous_in = previous.z > 0.0;\n        if (current_in != previous_in)\n        {\n            float t = previous.z / (previous.z - current.z);\n            clipped[n++] = float3(previous.xy + t * (current.xy - previous.xy), 0.0);\n        }\n        if (current_in)\n        {\n            clipped[n++] = current;\n        }\n    }\n    return n;\n}\n\n// Twice the contribution of one edge of the spherical polygon. The cross\n// product\'s z carries the sign, so a reversed winding flips the whole sum,\n// which is what tells a front-facing polygon from a back-facing one.\nfloat integrate_edge(float3 v1, float3 v2)\n{\n    float cos_theta = clamp(dot(v1, v2), -1.0, 1.0);\n    float theta     = acos(cos_theta);\n    float sin_theta = sqrt(max(1.0 - cos_theta * cos_theta, 0.0));\n    float ratio     = (sin_theta > 1e-4) ? (theta / sin_theta) : 1.0;\n    return cross(v1, v2).z * ratio;\n}\n\n// Fraction of the clamped-cosine distribution the quad covers, in [0, 1].\n// `m_inv` is the LTC inverse transform (rows follow mul(v, M) convention), or\n// the identity for the diffuse term.\nfloat ltc_evaluate(float3 N, float3 V, float3 P, float3x3 m_inv, float3 corners[4], bool two_sided)\n{\n    // Shading frame with the normal on +z and the first tangent in the view\n    // plane, matching how the table was fitted.\n    float3 t1 = normalize(V - N * dot(V, N));\n    float3 t2 = cross(N, t1);\n\n    float3 quad[4];\n    for (int i = 0; i < 4; i++)\n    {\n        float3 d = corners[i] - P;\n        float3 local = float3(dot(t1, d), dot(t2, d), dot(N, d));\n        quad[i] = mul(local, m_inv);\n    }\n\n    float3 clipped[5];\n    int n = clip_quad_to_horizon(quad, clipped);\n    if (n < 3)\n    {\n        return 0.0;\n    }\n    for (int k = 0; k < n; k++)\n    {\n        clipped[k] = normalize(clipped[k]);\n    }\n\n    float sum = 0.0;\n    for (int e = 0; e < n; e++)\n    {\n        sum += integrate_edge(clipped[e], clipped[(e + 1) % n]);\n    }\n\n    // The edge sum is twice the irradiance; dividing by pi normalises the\n    // clamped cosine, so the covered fraction is sum / (2 * pi).\n    float form_factor = sum / (2.0 * PI);\n    return two_sided ? abs(form_factor) : max(-form_factor, 0.0);\n}\n\n// 5x5 hash-rotated PCF of a single cascade. Returns the shadow factor in\n// [0, 1] (1.0 fully lit), or 1.0 when the fragment lies outside this\n// cascade\'s light frustum.\nfloat sample_cascade_pcf(int cascade, float3 world_pos, float2 screen_xy)\n{\n    float4 lc = mul(SHADOW_UNI.light_vps[cascade], float4(world_pos, 1.0));\n    float3 ndc = lc.xyz / lc.w;\n    float2 uv = float2(ndc.x * 0.5 + 0.5, -ndc.y * 0.5 + 0.5);\n    if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0 || ndc.z < 0.0 || ndc.z > 1.0)\n    {\n        return 1.0;\n    }\n\n    // Depth bias as a world-space offset along the light: in NDC that is the\n    // world offset over the cascade depth range, i.e. world_bias * length(VP\n    // row2 xyz) (the ortho z scale). The (1 + cascade * 2) factor grows that\n    // offset with cascade index: a distant cascade covers more world per\n    // shadow texel, so a flat bias under-biases the far cascades and leaves\n    // self-shadow acne that steps at each cascade boundary. On Metal that\n    // per-cascade growth is already applied by the shadow-pass rasterizer\'s\n    // depth bias (metal/draw/shadow.rs), so the sample-side bias stays flat\n    // there; applying both would double-bias the far cascades.\n    // Slang indexes HLSL-style (`m[i][j]` is row i, column j), unlike the GLSL\n    // and MSL counterparts where `m[i]` is column i: row 2 is spelled with the\n    // indices in the opposite order there.\n    float3 vp_row2 = float3(SHADOW_UNI.light_vps[cascade][2][0],\n                            SHADOW_UNI.light_vps[cascade][2][1],\n                            SHADOW_UNI.light_vps[cascade][2][2]);\n    float bias = 0.03 * cascade_bias_scale(cascade) * length(vp_row2);\n    float ref = ndc.z - bias;\n\n    float angle = hash_rotation(screen_xy);\n    float ca = cos(angle);\n    float sa = sin(angle);\n\n    float2 tex_size = 1.0 / shadow_map_size();\n\n    float sum = 0.0;\n    const int RADIUS = 2;\n    const float SAMPLES = float((2 * RADIUS + 1) * (2 * RADIUS + 1));\n    for (int dy = -RADIUS; dy <= RADIUS; dy++)\n    {\n        for (int dx = -RADIUS; dx <= RADIUS; dx++)\n        {\n            float2 off = float2(float(dx), float(dy));\n            float2 rot = float2(off.x * ca - off.y * sa, off.x * sa + off.y * ca);\n            float2 sample_uv = uv + rot * tex_size;\n            sum += shadow_map_cmp(float3(sample_uv, float(cascade)), ref);\n        }\n    }\n    return sum / SAMPLES;\n}\n\n// Cascade-aware PCF with cross-cascade blending. Selects the cascade whose far\n// split exceeds the fragment\'s view-space depth, then blends into the next\n// cascade across a band at the far edge of that cascade\'s depth range: each\n// cascade places the shadow edge slightly differently, and a hard switch would\n// sweep across the world as the camera moves.\nfloat shadow_factor_cascaded(float3 world_pos, float view_depth, float2 screen_xy)\n{\n    int cascade = 4;\n    if      (view_depth < SHADOW_UNI.cascade_splits[0]) cascade = 0;\n    else if (view_depth < SHADOW_UNI.cascade_splits[1]) cascade = 1;\n    else if (view_depth < SHADOW_UNI.cascade_splits[2]) cascade = 2;\n    else if (view_depth < SHADOW_UNI.cascade_splits[3]) cascade = 3;\n    if (cascade >= int(SHADOW_UNI.active_cascades))\n    {\n        return 1.0;\n    }\n\n    float shade = sample_cascade_pcf(cascade, world_pos, screen_xy);\n\n    if (cascade + 1 < int(SHADOW_UNI.active_cascades))\n    {\n        float split_far  = SHADOW_UNI.cascade_splits[cascade];\n        float split_near = (cascade == 0) ? 0.0 : SHADOW_UNI.cascade_splits[cascade - 1];\n        float band = (split_far - split_near) * 0.15;\n        float t = (view_depth - (split_far - band)) / max(band, 1e-4);\n        if (t > 0.0)\n        {\n            float next = sample_cascade_pcf(cascade + 1, world_pos, screen_xy);\n            shade = lerp(shade, next, clamp(t, 0.0, 1.0));\n        }\n    }\n    return shade;\n}\n\n{PROBE_COMMON}\n\n// ---- Fragment ----\n\n[shader(\"fragment\")]\nfloat4 fragment_main_bindless(VertexOut in) : SV_Target\n{\n    GpuObjectData od = OBJECTS[in.object_id];\n    float roughness = od.tint_roughness.w;\n    float metallic  = od.emissive_metallic.w;\n    float3 tint     = od.tint_roughness.xyz;\n    float3 emissive = od.emissive_metallic.xyz;\n\n    float3 cam_pos = float3(VIEW.cam_x, VIEW.cam_y, VIEW.cam_z);\n    bool ibl_enabled = VIEW.prefilter_mip_count > 0.5;\n\n    // Skybox sentinel (blue channel 2.0): sky colour from the view direction.\n    if (in.color.b > 1.5)\n    {\n        float3 view_dir = normalize(in.world_pos - cam_pos);\n        float3 sky;\n        if (ibl_enabled)\n        {\n            sky = prefilter_sample_level0(view_dir);\n        }\n        else\n        {\n            float t = max(0.0, view_dir.y);\n            sky = lerp(SKY_HORIZON, SKY_ZENITH, t);\n        }\n        return float4(sky, 1.0);\n    }\n\n    // `object_id` is flat from the instance index, so a fragment wave that\n    // straddles two objects of one indirect draw carries two pool indices.\n    // That makes every pool index non-uniform on the descriptor-indexing\n    // targets (pool_sample annotates it there).\n    float4 albedo_samp = pool_sample(od.albedo_index, in.uv);\n    // Alpha cutout: punch the texel out entirely so foliage and decal cards\n    // stay in the opaque pass. Disabled at cutoff 0.\n    if (od.alpha_cutoff > 0.0 && albedo_samp.a < od.alpha_cutoff)\n    {\n        discard;\n    }\n    float3 albedo = albedo_samp.rgb * in.color * tint;\n\n    // Unlit view mode: the surface\'s base color, no lighting.\n    if (VIEW.shade_mode > 0.5)\n    {\n        return float4(albedo, 1.0);\n    }\n\n    // Per-material emissive texture carries the colour (the scalar factor is a\n    // uniform strength when a map is bound). Slot 0 is the \"no map\" sentinel.\n    if (od.emissive_map_index != 0u)\n    {\n        emissive *= pool_sample(od.emissive_map_index, in.uv).rgb;\n    }\n\n    // Occlusion-roughness-metallic map: green carries roughness, blue carries\n    // metallic (glTF convention). Slot 0 is the \"no map\" sentinel.\n    if (od.orm_map_index != 0u)\n    {\n        float3 orm = pool_sample(od.orm_map_index, in.uv).rgb;\n        roughness = orm.g;\n        metallic  = orm.b;\n    }\n\n    float3 norm_samp = decode_normal_map(pool_sample(od.normal_index, in.uv).rg);\n    // Tangent frame as rows so mul(v, M) applies the column-basis transform.\n    float3x3 TBN = float3x3(\n        normalize(in.tangent),\n        normalize(in.bitangent),\n        normalize(in.normal));\n    float3 N = normalize(mul(norm_samp, TBN));\n\n    // Geometric specular antialiasing on the normal map. Minification aliasing\n    // is handled by the texture\'s mip chain (trilinear + anisotropic\n    // sampling); this widens the specular NDF for residual sub-pixel normal\n    // variance.\n    roughness = specular_aa_roughness(N, roughness);\n\n    float3 V   = normalize(cam_pos - in.world_pos);\n    float NdV  = max(dot(N, V), 0.0);\n\n    float3 F0 = lerp(float3(0.04), albedo, metallic);\n\n    float2 screen_xy = in.position.xy;\n    float shadow = shadow_factor_cascaded(in.world_pos, in.view_depth, screen_xy);\n\n    float2 ab        = env_brdf_approx(NdV, roughness);\n    float ess        = ab.x + ab.y;\n    float3 energy_ms = 1.0 + F0 * (1.0 / max(ess, 0.001) - 1.0);\n\n    float3 Lo = float3(0.0);\n\n    for (int i = 0; i < LIGHTS.num_dir; i++)\n    {\n        float3 L = normalize(LIGHTS.dir[i].dir_i.xyz);\n        float intensity = LIGHTS.dir[i].dir_i.w;\n        float3 radiance = LIGHTS.dir[i].col.xyz * intensity;\n\n        float3 H = normalize(V + L);\n        float NdL = max(dot(N, L), 0.0);\n\n        float D = distribution_ggx(N, H, roughness);\n        float G = geometry_smith(N, V, L, roughness);\n        float3 F = fresnel_schlick(max(dot(H, V), 0.0), F0);\n\n        float3 kd = (1.0 - F) * (1.0 - metallic);\n        float3 spec = (D * G * F) / max(4.0 * NdV * NdL, 0.001) * energy_ms;\n        float3 diff = kd * albedo / PI;\n\n        float s = (i == 0) ? shadow : 1.0;\n        Lo += (diff + spec) * radiance * NdL * s;\n    }\n\n    // Clustered light iteration: when clustering is active (the main camera),\n    // map this fragment to its froxel cluster and shade only that cluster\'s\n    // binned lights. Planar / probe re-renders bind use_clusters = 0 (their\n    // viewpoint differs from the grid the main camera binned) and fall back\n    // to iterating every local light.\n    uint cluster_base = 0u;\n    int  local_count;\n    if (CLUSTER.use_clusters != 0u)\n    {\n        uint cx = min(uint(screen_xy.x / CLUSTER.screen_w * float(CLUSTER.grid_x)),\n                      CLUSTER.grid_x - 1u);\n        uint cy = min(uint(screen_xy.y / CLUSTER.screen_h * float(CLUSTER.grid_y)),\n                      CLUSTER.grid_y - 1u);\n        float zd = max(in.view_depth, CLUSTER.cam_pos_znear.w);\n        uint cz = min(uint(log(zd / CLUSTER.cam_pos_znear.w) / log(CLUSTER.view_forward_zfar.w / CLUSTER.cam_pos_znear.w)\n                           * float(CLUSTER.grid_z)),\n                      CLUSTER.grid_z - 1u);\n        uint cid = cx + cy * CLUSTER.grid_x + cz * CLUSTER.grid_x * CLUSTER.grid_y;\n        cluster_base = cid * CLUSTER_LIGHT_LIST_STRIDE;\n        local_count = int(CLUSTER_LIST[cluster_base]);\n    }\n    else\n    {\n        local_count = LIGHTS.num_local_lights;\n    }\n\n    for (int jj = 0; jj < local_count; jj++)\n    {\n        int i = (CLUSTER.use_clusters != 0u)\n              ? int(CLUSTER_LIST[cluster_base + 1u + uint(jj)])\n              : jj;\n        float3 pos_w  = LOCAL_LIGHTS[i].position_range.xyz;\n        float  range  = LOCAL_LIGHTS[i].position_range.w;\n        float3 col    = LOCAL_LIGHTS[i].color_intensity.xyz;\n        float  intens = LOCAL_LIGHTS[i].color_intensity.w;\n\n        // Area lights integrate the whole panel rather than a single\n        // direction, so they replace the point / spot BRDF evaluation.\n        if (light_kind(LOCAL_LIGHTS[i]) == LIGHT_KIND_AREA)\n        {\n            int ai = LOCAL_LIGHTS[i].data_index;\n            if (ai < 0)\n            {\n                continue;\n            }\n            float3 centre = pos_w;\n            float3 right  = AREA_LIGHTS[ai].right_two_sided.xyz;\n            float3 up     = AREA_LIGHTS[ai].up_pad.xyz;\n            bool two_sided = asuint(AREA_LIGHTS[ai].right_two_sided.w) != 0u;\n\n            // Range is a cutoff measured from the panel centre, matching the\n            // sphere the clustered cull bins this light with. The physical\n            // falloff is already in the form factor: the panel subtends a\n            // smaller solid angle further away.\n            float centre_dist = length(centre - in.world_pos);\n            float window = clamp(1.0 - centre_dist / range, 0.0, 1.0);\n            window = window * window;\n            if (window <= 0.0)\n            {\n                continue;\n            }\n\n            float3 corners[4];\n            corners[0] = centre - right - up;\n            corners[1] = centre + right - up;\n            corners[2] = centre + right + up;\n            corners[3] = centre - right + up;\n\n            // Diffuse needs no lookup: it is the polygon integral under the\n            // plain clamped cosine, i.e. an identity transform.\n            float3x3 identity = float3x3(\n                float3(1.0, 0.0, 0.0),\n                float3(0.0, 1.0, 0.0),\n                float3(0.0, 0.0, 1.0));\n            float diffuse_ff = ltc_evaluate(N, V, in.world_pos, identity, corners, two_sided);\n\n            // Specular applies the fitted transform before the same integral.\n            float2 lut_uv = float2(roughness, sqrt(clamp(1.0 - NdV, 0.0, 1.0)));\n            lut_uv = lut_uv * LTC_LUT_SCALE + LTC_LUT_BIAS;\n            float4 t1 = ltc_matrix_sample(lut_uv);\n            float2 t2 = ltc_magnitude_sample(lut_uv);\n            // The table stores the inverse normalised so its middle entry is\n            // 1, packed as (m00, m20, m02, m22). Rows here follow the\n            // mul(v, M) convention, matching the GLSL / MSL column form.\n            float3x3 m_inv = float3x3(\n                float3(t1.x, 0.0, t1.y),\n                float3(0.0,  1.0, 0.0),\n                float3(t1.z, 0.0, t1.w));\n            float specular_ff = ltc_evaluate(N, V, in.world_pos, m_inv, corners, two_sided);\n            // Schlick split baked into the table: t2.x weights the base\n            // reflectance, t2.y the grazing response.\n            float3 area_spec = F0 * t2.x + (1.0 - F0) * t2.y;\n\n            float3 area_radiance = col * intens * window;\n            float3 area_kd = (1.0 - F0) * (1.0 - metallic);\n            Lo += area_radiance * (area_kd * albedo * diffuse_ff\n                                   + area_spec * specular_ff);\n            continue;\n        }\n\n        float3 L    = normalize(pos_w - in.world_pos);\n        float  dist = length(pos_w - in.world_pos);\n        float atten = clamp(1.0 - (dist / range), 0.0, 1.0);\n        atten *= atten;\n        // Spot cone: full brightness inside cos_inner, squared fade to black\n        // at cos_outer. Point lights leave both at zero and skip this.\n        if (light_kind(LOCAL_LIGHTS[i]) == LIGHT_KIND_SPOT)\n        {\n            float cd = dot(LOCAL_LIGHTS[i].direction_kind.xyz, -L);\n            float ci = LOCAL_LIGHTS[i].cos_inner;\n            float co = LOCAL_LIGHTS[i].cos_outer;\n            float t  = clamp((cd - co) / max(ci - co, 1e-4), 0.0, 1.0);\n            atten *= t * t;\n            // Only spots that claimed a shadow slice sample the array; the\n            // rest keep shadow_index at -1 and light without casting.\n            int si = LOCAL_LIGHTS[i].shadow_index;\n            if (si >= 0 && atten > 0.0)\n            {\n                atten *= sample_spot_shadow(si, in.world_pos, N, screen_xy);\n            }\n        }\n        float3 radiance = col * intens * atten;\n\n        float3 H  = normalize(V + L);\n        float NdL = max(dot(N, L), 0.0);\n\n        float D = distribution_ggx(N, H, roughness);\n        float G = geometry_smith(N, V, L, roughness);\n        float3 F = fresnel_schlick(max(dot(H, V), 0.0), F0);\n\n        float3 kd   = (1.0 - F) * (1.0 - metallic);\n        float3 spec = (D * G * F) / max(4.0 * NdV * NdL, 0.001) * energy_ms;\n        float3 diff = kd * albedo / PI;\n        Lo += (diff + spec) * radiance * NdL;\n    }\n\n    float3 ambient;\n    if (ibl_enabled)\n    {\n        float3 F_ibl       = fresnel_schlick(NdV, F0);\n        float3 kd_ibl      = (1.0 - F_ibl) * (1.0 - metallic);\n        float3 irradiance  = irradiance_sample(N);\n        float3 diffuse_ibl = kd_ibl * albedo * irradiance / PI;\n\n        float3 R = reflect(-V, N);\n        // SampleBias (not SampleLevel) so the reflection vector\'s screen-space\n        // footprint widens the mip at grazing or distant angles. A forced LOD\n        // defeats minification filtering and aliases the environment into\n        // sparkle on near mirrors; flat close-up pixels have a near-zero\n        // footprint, so they keep the plain roughness mip.\n        float lod = roughness * (VIEW.prefilter_mip_count - 1.0);\n        // Local reflection probes when any are baked (box-parallax partition\n        // of unity), else the imported environment prefilter cube. With no\n        // probe baked the probe count is 0, so this is the sky path unchanged.\n        float3 prefiltered = (PROBE_SET.count > 0u)\n            ? probe_set_specular(in.world_pos, R, lod)\n            : prefilter_sample_bias(R, lod);\n        float3 specular_ibl = prefiltered * (F0 * ab.x + ab.y);\n\n        // When an SSR / RT reflection composite owns the sharp specular for\n        // glossy surfaces this frame, fade the forward probe specular for\n        // glossy dielectrics so the two do not double-count. Metals keep\n        // their full albedo-tinted forward specular (the resolve adds only a\n        // faint dielectric term), and surfaces rougher than the cut (which\n        // the resolve skips) keep theirs too.\n        if (VIEW.reflections_enabled > 0.5)\n        {\n            float fade = smoothstep(REFLECTION_ROUGHNESS_CUT * 0.7,\n                                    REFLECTION_ROUGHNESS_CUT, roughness);\n            specular_ibl *= lerp(1.0, fade, 1.0 - metallic);\n        }\n\n        ambient = diffuse_ibl + specular_ibl;\n    }\n    else\n    {\n        ambient = float3(0.03) * albedo;\n    }\n\n    // Authored indirect-fill multiplier (PostProcessConfig.ambient_intensity);\n    // 1.0 is a no-op. Lifts shadow fill without touching sun-lit surfaces.\n    ambient *= LIGHTS.ambient_intensity;\n\n    // SSAO modulates the indirect (ambient / IBL) term only: direct lighting\n    // is unaffected. A 1x1 white view is bound when SSAO is disabled, so this\n    // samples a constant 1.0 then.\n    float2 ssao_uv = screen_xy / ssao_size();\n    ambient *= ssao_sample(ssao_uv);\n\n    float3 color = ambient + Lo + emissive;\n\n    return float4(color, albedo_samp.a);\n}\n";
Expand description

main_bindless.slang.