diff --git a/docs/manual/editor/lighting.md b/docs/manual/editor/lighting.md new file mode 100644 index 0000000..dee215a --- /dev/null +++ b/docs/manual/editor/lighting.md @@ -0,0 +1,92 @@ +# Add lights to a 3D scene + +Add a **Light** component to a scene entity. The entity's transform places a point +or spot light; its rotation aims a spot light along local negative Z. A directional +light uses the entity's orientation. Light colors and intensity contribute to the +mesh's linear PBR illumination before tone mapping. Sprites and UI retain their +unlit tint. + +The version-1 `faset.light` component has three `kind` values: + +| Kind | Position and direction | Useful fields | +| --- | --- | --- | +| `directional` | Direction from the entity transform | `color`, `intensity`, `casts_shadow` | +| `point` | Position from the entity transform; illuminates every direction | `color`, `intensity`, `range` | +| `spot` | Position and local negative-Z direction | `color`, `intensity`, `range`, `inner_angle`, `outer_angle` | + +Angles are radians. A spot's inner angle must not exceed its outer angle. Intensity +must be nonnegative and range positive. `enabled: false` keeps the component in the +scene without contributing light. The `shadow_priority` integer is reserved for the +bounded local-shadow scheduler; it does not change brightness. + +In the current rendering checkpoint, one enabled directional light can cast the +existing single-map shadow. Point and spot lights illuminate meshes but do not yet +cast shadows. The [P3 lighting plan](https://github.com/emil28092005/Faset_Engine/blob/main/docs/superpowers/plans/2026-09-24-p3-lighting.md) +tracks cascades and the bounded local-shadow atlas. A scene with no Light component +keeps the legacy white sun so older projects retain their appearance. Adding any +Light component, even a disabled one, turns off that compatibility fallback. If +several directionals are enabled, Faset chooses the one with the smallest stable +entity ID and reports a diagnostic for the others. + +## Author a point light through MCP + +Use `faset_schema` to inspect the current field IDs, then send a `faset_scene_edit` +batch with the document ID, current revision, and target entity ID. For example: + +```json +{ + "document": "REPLACE_WITH_DOCUMENT_ID", + "revision": 4, + "idempotency_key": "add-red-point-light", + "operations": [{ + "op": "component.add", + "entity": "REPLACE_WITH_ENTITY_ID", + "type": "faset.light", + "fields": { + "kind": "point", + "color": [1, 0.15, 0.1, 1], + "intensity": 8, + "range": 6 + } + }] +} +``` + +Move the entity with its Transform component. `component.add` fills any omitted +light fields from the version-1 schema; use `component.set` for later edits. See +[MCP and command line](mcp.md) for revision and retry handling. + +## Supply lights directly from C++ + +When building a `faset::render::Snapshot` yourself, set +`authored_lights_present` to suppress the compatibility sun in a local-only scene. +Provide a stable ID for each light so future shadow scheduling remains independent +of submission order. + +```cpp +faset::render::Snapshot snapshot; +snapshot.authored_lights_present = true; + +faset::render::LocalLight point; +point.kind = faset::render::LocalLight::Kind::Point; +point.stable_id = "level/torch"; +point.position = {-2, 1.5f, 0}; +point.color = {1, 0.3f, 0.1f, 1}; +point.intensity = 8; +point.range = 6; +snapshot.local_lights.push_back(point); + +faset::render::LocalLight spot; +spot.kind = faset::render::LocalLight::Kind::Spot; +spot.stable_id = "level/lamp"; +spot.position = {2, 3, 0}; +spot.direction = {0, -1, 0}; +spot.inner_angle = 0.25f; +spot.outer_angle = 0.55f; +spot.intensity = 5; +spot.range = 9; +snapshot.local_lights.push_back(spot); +``` + +The renderer submits at most 128 local lights per frame in stable-ID order. Later +P3 work adds explicit overflow diagnostics and measured light-list optimization. diff --git a/mkdocs.yml b/mkdocs.yml index 3622a6d..6d35b79 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -44,6 +44,7 @@ nav: - Editor workspace: editor/workspace.md - Scene templates: editor/templates.md - Assets and Blender: editor/assets.md + - Lighting: editor/lighting.md - GPU visibility and mesh LOD: editor/visibility-lod.md - Build, Play, and export: editor/export.md - Profiling and measurements: editor/profiling.md diff --git a/shaders/baseline.slang b/shaders/baseline.slang index 92cf8da..9f2bb22 100644 --- a/shaders/baseline.slang +++ b/shaders/baseline.slang @@ -24,6 +24,32 @@ struct FrameParameters { [[vk::binding(1,0)]] SamplerState shadowSampler; [[vk::binding(2,0)]] Texture2D colorMap; [[vk::binding(3,0)]] SamplerState colorSampler; +// Shared Direct/P2 graphics ABI. The legacy material set remains set 0; +// GPU-only instance/visibility records occupy set 2. +struct LightingHeader { + uint4 counts; // local count, sun enabled, sun shadow enabled, view count + float4 sunDirectionIntensity; // xyz world-space ray direction, w intensity + float4 sunColor; + float4 cameraForwardShadowDistance; + float4 cascadeSplits; +}; +struct LocalLightGpu { + float4 positionRange; + float4 directionCosOuter; + float4 colorIntensity; + float4 coneTypeShadowView; // cos(inner), 0=point/1=spot, shadow view, flags + float4 reserved; +}; +struct ShadowViewGpu { + column_major float4x4 viewProjection; + float4 tileScaleOffset; + float4 guardedClamp; + float4 biasFlags; +}; +[[vk::binding(0,1)]] StructuredBuffer lightingFrame; +[[vk::binding(1,1)]] StructuredBuffer localLights; +[[vk::binding(2,1)]] StructuredBuffer shadowViews; +[[vk::binding(3,1)]] Texture2D localShadowAtlas; [shader("vertex")] VertexOutput vertexMain(VertexInput v) { VertexOutput o; @@ -32,6 +58,22 @@ VertexOutput vertexMain(VertexInput v) { } [shader("vertex")] float4 shadowMain(VertexInput v) : SV_Position { return mul(frame.lightViewProjection, float4(v.world,1)); } +float3 directBRDF(float3 base, float rough, float metal, float3 n, float3 view, float3 l) { + const float pi = 3.14159265; + float nl = max(dot(n,l),0.0); + if (nl <= 0.0) return float3(0); + float3 halfVector = l + view; + float halfLengthSquared = dot(halfVector, halfVector); + float3 h = halfLengthSquared > 1e-8 ? halfVector * rsqrt(halfLengthSquared) : n; + float nv=max(dot(n,view),0.001), nh=max(dot(n,h),0.0), vh=max(dot(view,h),0.0); + float a=rough*rough, a2=a*a, denom=nh*nh*(a2-1.0)+1.0; + float d=a2/(pi*denom*denom+0.0001); + float k=(rough+1.0)*(rough+1.0)/8.0; + float g=(nl/(nl*(1.0-k)+k))*(nv/(nv*(1.0-k)+k)); + float3 f0=lerp(float3(0.04),base,metal), fresnel=f0+(1.0-f0)*pow(1.0-vh,5.0); + float3 spec=d*g*fresnel/max(4.0*nv*nl,0.001); + return ((1.0-fresnel)*(1.0-metal)*base/pi+spec)*nl; +} [shader("fragment")] float4 fragmentMain(VertexOutput v) : SV_Target { float4 sampled = colorMap.Sample(colorSampler, v.uv); @@ -41,28 +83,52 @@ float4 fragmentMain(VertexOutput v) : SV_Target { return v.color * sampled; } float4 base = v.color * sampled; - const float pi = 3.14159265; - float3 n=normalize(v.normal), l=normalize(-frame.lightDirection.xyz), view=normalize(frame.eye.xyz-v.world), h=normalize(l+view); - float nl=max(dot(n,l),0.0), nv=max(dot(n,view),0.001), nh=max(dot(n,h),0.0), vh=max(dot(view,h),0.0); + LightingHeader lighting = lightingFrame[0]; + float3 n=normalize(v.normal); + float3 viewDelta=frame.eye.xyz-v.world; + float viewLengthSquared=dot(viewDelta,viewDelta); + float3 view=viewLengthSquared > 1e-8 ? viewDelta*rsqrt(viewLengthSquared) : n; float rough=clamp(v.material.x,0.08,1.0), metal=saturate(v.material.y); - float a=rough*rough, a2=a*a, denom=nh*nh*(a2-1.0)+1.0; - float d=a2/(pi*denom*denom+0.0001); - float k=(rough+1.0)*(rough+1.0)/8.0; - float g=(nl/(nl*(1.0-k)+k))*(nv/(nv*(1.0-k)+k)); - float3 f0=lerp(float3(0.04),base.rgb,metal), fresnel=f0+(1.0-f0)*pow(1.0-vh,5.0); - float3 spec=d*g*fresnel/max(4.0*nv*nl,0.001); - float4 lightClip=mul(frame.lightViewProjection,float4(v.world,1)); - float3 projected=lightClip.xyz/lightClip.w; - float2 uv=projected.xy*.5+.5; - float visibility=1.0; - if(all(uv>=0.0)&&all(uv<=1.0)&&projected.z>=0.0&&projected.z<=1.0) { - visibility=0.0; - for(int y=-1;y<=1;++y) for(int x=-1;x<=1;++x) { - float depth=shadowMap.SampleLevel(shadowSampler,uv+float2(x,y)/1024.0,0); - visibility += projected.z-max(0.0008,0.003*(1.0-nl)) <= depth ? 1.0/9.0 : 0.0; + float3 linear=base.rgb*.12; + if (lighting.counts.y != 0 && lighting.sunDirectionIntensity.w > 0) { + float3 l=normalize(-lighting.sunDirectionIntensity.xyz); + float nl=max(dot(n,l),0.0); + float visibility=1.0; + if (lighting.counts.z != 0 && nl > 0) { + float4 lightClip=mul(frame.lightViewProjection,float4(v.world,1)); + float3 projected=lightClip.xyz/lightClip.w; + float2 uv=projected.xy*.5+.5; + if(all(uv>=0.0)&&all(uv<=1.0)&&projected.z>=0.0&&projected.z<=1.0) { + visibility=0.0; + for(int y=-1;y<=1;++y) for(int x=-1;x<=1;++x) { + float depth=shadowMap.SampleLevel(shadowSampler,uv+float2(x,y)/1024.0,0); + visibility += projected.z-max(0.0008,0.003*(1.0-nl)) <= depth ? 1.0/9.0 : 0.0; + } + } } + linear += directBRDF(base.rgb, rough, metal, n, view, l) * + lighting.sunColor.rgb * (lighting.sunDirectionIntensity.w * 3.0 * visibility); + } + for (uint i=0; i= range || light.colorIntensity.w <= 0) continue; + float3 l=delta/distance; + float relative=distance/range; + float cutoff=1.0-relative*relative*relative*relative; + float attenuation=cutoff*cutoff/(1.0+distanceSquared); + if (light.coneTypeShadowView.y > 0.5) { + float cosAngle=dot(-l,normalize(light.directionCosOuter.xyz)); + float denominator=max(light.coneTypeShadowView.x-light.directionCosOuter.w,1e-4); + float cone=saturate((cosAngle-light.directionCosOuter.w)/denominator); + attenuation *= cone*cone*(3.0-2.0*cone); + } + linear += directBRDF(base.rgb, rough, metal, n, view, l) * + light.colorIntensity.rgb * (light.colorIntensity.w * attenuation); } - float3 linear=base.rgb*.12 + ((1.0-fresnel)*(1.0-metal)*base.rgb/pi+spec)*nl*3.0*visibility; linear=linear/(1.0+linear); return float4(pow(max(linear,0),float3(1.0/2.2)),base.a); } diff --git a/shaders/gpu_scene.slang b/shaders/gpu_scene.slang index 30d5182..8e8283d 100644 --- a/shaders/gpu_scene.slang +++ b/shaders/gpu_scene.slang @@ -57,9 +57,9 @@ struct GpuFrameParameters { uint4 drawInfo; // x=visible ID range base; firstInstance is always zero }; [[vk::push_constant]] ConstantBuffer gpuFrame; -[[vk::binding(0,1)]] StructuredBuffer gfxInstances; -[[vk::binding(1,1)]] StructuredBuffer gfxVisibleIds; -[[vk::binding(2,1)]] StructuredBuffer gfxViews; +[[vk::binding(0,2)]] StructuredBuffer gfxInstances; +[[vk::binding(1,2)]] StructuredBuffer gfxVisibleIds; +[[vk::binding(2,2)]] StructuredBuffer gfxViews; // All indirect commands use firstInstance=0. The raw Vulkan index avoids the // BaseInstance read that Slang adds for SV_InstanceID (DrawParameters feature). diff --git a/src/authoring/schema.cpp b/src/authoring/schema.cpp index 63366e6..9b255f7 100644 --- a/src/authoring/schema.cpp +++ b/src/authoring/schema.cpp @@ -156,6 +156,15 @@ void SchemaRegistry::validate_component(const Json& component) const { for (const auto& [id, value] : component["fields"].items()) if (metadata["fields"].contains(id)) validate_field(value, metadata["fields"][id]); + if (type == "faset.light") { + auto effective = default_fields(type); + effective.update(component.at("fields")); + if (effective.at("kind") == "spot") + require(effective.at("inner_angle").get() <= + effective.at("outer_angle").get(), + "validation.light_cone", + "Spotlight inner_angle must not exceed outer_angle"); + } } void SchemaRegistry::add_migration(const std::string& type, int from_version, Json rules) { require(contains(type) && from_version > 0 && from_version < schema(type).value("version", 1) && @@ -267,7 +276,10 @@ SchemaRegistry builtin_schemas() { {"default", 0.7}, {"min", 0.001}, {"max", 1.55}, {"unit", "radians"}}}, {"casts_shadow", field("boolean", true)}, - {"shadow_priority", field("integer", 0)}}); + {"shadow_priority", Json{{"type", "integer"}, + {"default", 0}, + {"min", std::numeric_limits::min()}, + {"max", std::numeric_limits::max()}}}}); for (int dimension : {2, 3}) { Json vector = dimension == 2 ? Json{0, 0} : Json{0, 0, 0}; Json extents = dimension == 2 ? Json{0.5, 0.5} : Json{0.5, 0.5, 0.5}; diff --git a/src/player/SceneView.cpp b/src/player/SceneView.cpp index 12a206d..87d2063 100644 --- a/src/player/SceneView.cpp +++ b/src/player/SceneView.cpp @@ -397,7 +397,9 @@ render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSetting for (const auto coordinate : local.position) if (!std::isfinite(coordinate)) invalid("position"); - local.direction = normalized(direction(model, {0, 0, -1}), id, "direction"); + if (local.kind == render::LocalLight::Kind::Spot) + local.direction = normalized(direction(model, {0, 0, -1}), id, + "direction"); local.color = color; local.intensity = intensity; local.range = number("range", 10); @@ -405,13 +407,19 @@ render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSetting invalid("range"); local.inner_angle = number("inner_angle", 0.35f); local.outer_angle = number("outer_angle", 0.7f); - if (local.inner_angle < 0 || local.inner_angle > local.outer_angle || - local.outer_angle <= 0 || local.outer_angle >= std::numbers::pi_v / 2) + if (local.kind == render::LocalLight::Kind::Spot && + (local.inner_angle < 0 || local.inner_angle > local.outer_angle || + local.outer_angle <= 0 || + local.outer_angle >= std::numbers::pi_v / 2)) invalid("inner_angle/outer_angle"); local.casts_shadow = castsShadow; if (fields.contains("shadow_priority")) { if (!fields.at("shadow_priority").is_number_integer()) invalid("shadow_priority"); + const auto priority = fields.at("shadow_priority").get(); + if (priority < std::numeric_limits::min() || + priority > std::numeric_limits::max()) + invalid("shadow_priority"); local.shadow_priority = fields.at("shadow_priority").get(); } out.local_lights.push_back(std::move(local)); diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index 0a1f564..d99c7b8 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -14,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -72,6 +74,40 @@ struct ScenePush { std::array draw_info; }; static_assert(sizeof(ScenePush) == 112); +struct LightingHeaderGpu { + std::array counts{}; + std::array sun_direction_intensity{}; + std::array sun_color{}; + std::array camera_forward_shadow_distance{}; + std::array cascade_splits{}; +}; +struct LocalLightGpu { + std::array position_range{}; + std::array direction_cos_outer{}; + std::array color_intensity{}; + std::array cone_type_shadow_view{}; + std::array reserved{}; +}; +struct ShadowViewGpu { + Mat4 view_projection{identity}; + std::array tile_scale_offset{}; + std::array guarded_clamp{}; + std::array bias_flags{}; +}; +static_assert(sizeof(LightingHeaderGpu) == 80 && + offsetof(LightingHeaderGpu, sun_direction_intensity) == 16 && + offsetof(LightingHeaderGpu, sun_color) == 32 && + offsetof(LightingHeaderGpu, camera_forward_shadow_distance) == 48 && + offsetof(LightingHeaderGpu, cascade_splits) == 64); +static_assert(sizeof(LocalLightGpu) == 80 && + offsetof(LocalLightGpu, direction_cos_outer) == 16 && + offsetof(LocalLightGpu, color_intensity) == 32 && + offsetof(LocalLightGpu, cone_type_shadow_view) == 48 && + offsetof(LocalLightGpu, reserved) == 64); +static_assert(sizeof(ShadowViewGpu) == 112 && + offsetof(ShadowViewGpu, tile_scale_offset) == 64 && + offsetof(ShadowViewGpu, guarded_clamp) == 80 && + offsetof(ShadowViewGpu, bias_flags) == 96); std::array point(const Mat4& m, std::array p) { std::array o{}; for (int r = 0; r < 4; ++r) @@ -196,6 +232,7 @@ struct Renderer::Impl { std::vector swap_layouts; Image color, depth, shadow; Buffer vertices, readback; + Buffer lighting_header, lighting_locals, lighting_views; SceneResources scene; InstanceTracker instance_tracker; std::unordered_map previous_lods; @@ -212,6 +249,9 @@ struct Renderer::Impl { std::unordered_map opacity_cache; VkDescriptorSetLayout descriptor_layout{}; VkDescriptorPool descriptor_pool{}; + VkDescriptorSetLayout lighting_layout{}; + VkDescriptorPool lighting_pool{}; + VkDescriptorSet lighting_set{}; VkSampler shadow_sampler{}, color_sampler{}; VkPipelineLayout pipeline_layout{}; VkPipeline pipeline{}, ui_pipeline{}, shadow_pipeline{}, sprite_pipeline{}; @@ -316,6 +356,9 @@ struct Renderer::Impl { destroy(scene.hzb[1]); destroy(vertices); destroy(readback); + destroy(lighting_header); + destroy(lighting_locals); + destroy(lighting_views); destroy(color); destroy(depth); destroy(shadow); @@ -333,8 +376,12 @@ struct Renderer::Impl { vkDestroyPipelineLayout(device, pipeline_layout, nullptr); if (descriptor_pool) vkDestroyDescriptorPool(device, descriptor_pool, nullptr); + if (lighting_pool) + vkDestroyDescriptorPool(device, lighting_pool, nullptr); if (descriptor_layout) vkDestroyDescriptorSetLayout(device, descriptor_layout, nullptr); + if (lighting_layout) + vkDestroyDescriptorSetLayout(device, lighting_layout, nullptr); if (shadow_sampler) vkDestroySampler(device, shadow_sampler, nullptr); if (color_sampler) @@ -889,6 +936,31 @@ struct Renderer::Impl { pi.pPoolSizes = sizes; check(vkCreateDescriptorPool(device, &pi, nullptr, &descriptor_pool), "Create descriptor pool"); + std::array lighting_bindings{}; + for (std::uint32_t i = 0; i < lighting_bindings.size(); ++i) + lighting_bindings[i] = {i, + i == 3 ? VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE + : VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, + 1, VK_SHADER_STAGE_FRAGMENT_BIT, nullptr}; + li.bindingCount = static_cast(lighting_bindings.size()); + li.pBindings = lighting_bindings.data(); + check(vkCreateDescriptorSetLayout(device, &li, nullptr, &lighting_layout), + "Create lighting descriptor layout"); + VkDescriptorPoolSize lighting_sizes[] = { + {VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, 3}, {VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, 1}}; + pi.flags = 0; + pi.maxSets = 1; + pi.poolSizeCount = 2; + pi.pPoolSizes = lighting_sizes; + check(vkCreateDescriptorPool(device, &pi, nullptr, &lighting_pool), + "Create lighting descriptor pool"); + VkDescriptorSetAllocateInfo lighting_allocation{}; + lighting_allocation.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; + lighting_allocation.descriptorPool = lighting_pool; + lighting_allocation.descriptorSetCount = 1; + lighting_allocation.pSetLayouts = &lighting_layout; + check(vkAllocateDescriptorSets(device, &lighting_allocation, &lighting_set), + "Allocate lighting descriptors"); VkSamplerCreateInfo si{}; si.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO; si.magFilter = si.minFilter = VK_FILTER_NEAREST; @@ -1009,8 +1081,9 @@ struct Renderer::Impl { sizeof(Push)}; VkPipelineLayoutCreateInfo li{}; li.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO; - li.setLayoutCount = 1; - li.pSetLayouts = &descriptor_layout; + const std::array set_layouts{descriptor_layout, lighting_layout}; + li.setLayoutCount = static_cast(set_layouts.size()); + li.pSetLayouts = set_layouts.data(); li.pushConstantRangeCount = 1; li.pPushConstantRanges = &push; check(vkCreatePipelineLayout(device, &li, nullptr, &pipeline_layout), @@ -1220,14 +1293,14 @@ struct Renderer::Impl { layout.pBindings = hzb.data(); check(vkCreateDescriptorSetLayout(device, &layout, nullptr, &scene.hzb_layout), "Create HZB descriptor layout"); - const std::array scene_layouts{descriptor_layout, - scene.graphics_layout}; + const std::array scene_layouts{ + descriptor_layout, lighting_layout, scene.graphics_layout}; VkPushConstantRange graphics_push{VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 0, sizeof(ScenePush)}; VkPipelineLayoutCreateInfo pipeline_info{}; pipeline_info.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO; - pipeline_info.setLayoutCount = 2; + pipeline_info.setLayoutCount = static_cast(scene_layouts.size()); pipeline_info.pSetLayouts = scene_layouts.data(); pipeline_info.pushConstantRangeCount = 1; pipeline_info.pPushConstantRanges = &graphics_push; @@ -1533,6 +1606,29 @@ struct Renderer::Impl { VkBufferUsageFlags usage = 0) { upload_scene_buffer(buffer, values.data(), values.size() * sizeof(T), usage); } + void update_lighting_descriptors() { + const std::array buffers{{ + {lighting_header.handle, 0, lighting_header.size}, + {lighting_locals.handle, 0, lighting_locals.size}, + {lighting_views.handle, 0, lighting_views.size}}}; + const VkDescriptorImageInfo atlas{VK_NULL_HANDLE, shadow.view, + VK_IMAGE_LAYOUT_DEPTH_READ_ONLY_OPTIMAL}; + std::array writes{}; + for (std::uint32_t i = 0; i < writes.size(); ++i) { + writes[i].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[i].dstSet = lighting_set; + writes[i].dstBinding = i; + writes[i].descriptorCount = 1; + writes[i].descriptorType = i == 3 ? VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE + : VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; + if (i == 3) + writes[i].pImageInfo = &atlas; + else + writes[i].pBufferInfo = &buffers[i]; + } + vkUpdateDescriptorSets(device, static_cast(writes.size()), + writes.data(), 0, nullptr); + } void update_scene_descriptors(bool occlusion) { auto write_buffers = [&](VkDescriptorSet set, std::span buffers, std::uint32_t first_binding) { @@ -2017,15 +2113,87 @@ struct Renderer::Impl { VK_BUFFER_USAGE_TRANSFER_DST_BIT); update_scene_descriptors(occlusion); } - Vec3 direction = snapshot.light_direction; + std::optional sun = snapshot.sun; + if (!sun && !snapshot.authored_lights_present && snapshot.local_lights.empty()) + sun = SunLight{"legacy-sun", snapshot.light_direction, {1, 1, 1, 1}, 1, true}; + Vec3 direction = sun ? sun->direction : snapshot.light_direction; float length = std::sqrt(direction[0] * direction[0] + direction[1] * direction[1] + direction[2] * direction[2]); - if (length < 1e-5f) { + if (!std::isfinite(length) || length < 1e-5f) { + if (sun && sun->stable_id != "legacy-sun") + throw std::invalid_argument("Authored sun direction must be finite and nonzero"); direction = {-.5f, -1, -.3f}; length = std::sqrt(1.34f); } for (auto& v : direction) v /= length; + LightingHeaderGpu lighting{}; + lighting.counts[1] = sun ? 1u : 0u; + lighting.counts[2] = sun && sun->casts_shadow ? 1u : 0u; + lighting.sun_direction_intensity = {direction[0], direction[1], direction[2], + sun ? sun->intensity : 0}; + lighting.sun_color = sun ? sun->color : Color{0, 0, 0, 1}; + if (sun && (!std::isfinite(sun->intensity) || sun->intensity < 0 || + std::any_of(sun->color.begin(), sun->color.end(), + [](float v) { return !std::isfinite(v) || v < 0; }))) + throw std::invalid_argument("Authored sun radiance must be finite and nonnegative"); + lighting.camera_forward_shadow_distance = {0, 0, -1, 80}; + if (snapshot.camera_frustum) { + const auto& view = snapshot.camera_frustum->view; + lighting.camera_forward_shadow_distance = {-view[2], -view[6], -view[10], 80}; + } + auto sorted_lights = snapshot.local_lights; + std::stable_sort(sorted_lights.begin(), sorted_lights.end(), + [](const auto& a, const auto& b) { return a.stable_id < b.stable_id; }); + constexpr std::size_t max_local_lights = 128; + std::vector gpu_lights; + gpu_lights.reserve(std::min(sorted_lights.size(), max_local_lights)); + for (const auto& local : sorted_lights) { + if (gpu_lights.size() == max_local_lights) + break; + const auto finite_color = std::all_of(local.color.begin(), local.color.end(), + [](float v) { return std::isfinite(v) && v >= 0; }); + const auto finite_position = std::all_of(local.position.begin(), local.position.end(), + [](float v) { return std::isfinite(v); }); + if (!finite_color || !finite_position || !std::isfinite(local.intensity) || + local.intensity < 0 || !std::isfinite(local.range) || local.range <= 0) + throw std::invalid_argument("Local light radiance, position and range must be finite"); + if (local.kind == LocalLight::Kind::Spot && + (!std::isfinite(local.inner_angle) || !std::isfinite(local.outer_angle) || + local.inner_angle < 0 || local.inner_angle > local.outer_angle || + local.outer_angle >= std::numbers::pi_v / 2)) + throw std::invalid_argument("Spotlight cone angles are invalid"); + auto spot_direction = local.direction; + float spot_length = std::hypot(spot_direction[0], spot_direction[1], + spot_direction[2]); + if (!std::isfinite(spot_length) || spot_length < 1e-6f) { + if (local.kind == LocalLight::Kind::Spot) + throw std::invalid_argument("Spotlight direction must be finite and nonzero"); + spot_direction = {0, 0, -1}; + spot_length = 1; + } + for (auto& axis : spot_direction) + axis /= spot_length; + LocalLightGpu gpu{}; + gpu.position_range = {local.position[0], local.position[1], local.position[2], + local.range}; + const bool spot = local.kind == LocalLight::Kind::Spot; + gpu.direction_cos_outer = {spot_direction[0], spot_direction[1], spot_direction[2], + spot ? std::cos(local.outer_angle) : 0.f}; + gpu.color_intensity = {local.color[0], local.color[1], local.color[2], + local.intensity}; + gpu.cone_type_shadow_view = {spot ? std::cos(local.inner_angle) : 1.f, + spot ? 1.f : 0.f, -1, 0}; + gpu_lights.push_back(gpu); + } + lighting.counts[0] = static_cast(gpu_lights.size()); + if (gpu_lights.empty()) + gpu_lights.push_back({}); // Descriptors always point at a full initialized record. + const ShadowViewGpu empty_shadow_view{}; + upload_scene_buffer(lighting_header, &lighting, sizeof(lighting), 0); + upload_scene_vector(lighting_locals, gpu_lights); + upload_scene_buffer(lighting_views, &empty_shadow_view, sizeof(empty_shadow_view), 0); + update_lighting_descriptors(); Vec3 light_eye{-direction[0] * 30, -direction[1] * 30, -direction[2] * 30}; Vec3 light_up = std::abs(direction[1]) > .98f ? Vec3{0, 0, 1} : Vec3{0, 1, 0}; Push push{multiply(orthographic(-20, 20, -20, 20, .1f, 80), @@ -2078,6 +2246,12 @@ struct Renderer::Impl { vkCmdSetViewport(command, 0, 1, &viewport); vkCmdSetScissor(command, 0, 1, &scissor); }; + auto bind_material = [&](VkDescriptorSet material) { + const std::array sets{material, lighting_set}; + vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline_layout, + 0, static_cast(sets.size()), sets.data(), + 0, nullptr); + }; auto draw_transparent = [&] { if (transparent_batches.empty()) return; @@ -2088,8 +2262,7 @@ struct Renderer::Impl { 0, sizeof(push), &push); for (auto batch : transparent_batches) { auto descriptor = textures.at(batch.texture).descriptor; - vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS, - pipeline_layout, 0, 1, &descriptor, 0, nullptr); + bind_material(descriptor); vkCmdDraw(command, batch.count, 1, batch.first, 0); ++statistics.draw_calls; } @@ -2102,8 +2275,7 @@ struct Renderer::Impl { sizeof(push), &push); for (auto batch : sprite_batches) { auto descriptor = textures.at(batch.texture).descriptor; - vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline_layout, - 0, 1, &descriptor, 0, nullptr); + bind_material(descriptor); vkCmdDraw(command, batch.count, 1, batch.first, 0); ++statistics.draw_calls; } @@ -2125,8 +2297,7 @@ struct Renderer::Impl { continue; vkCmdSetScissor(command, 0, 1, &scissor); auto descriptor = textures.at(batch.texture).descriptor; - vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline_layout, - 0, 1, &descriptor, 0, nullptr); + bind_material(descriptor); vkCmdDraw(command, batch.count, 1, batch.first, 0); ++statistics.draw_calls; } @@ -2313,8 +2484,7 @@ struct Renderer::Impl { vkCmdPushConstants(command, pipeline_layout, VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 0, sizeof(push), &push); - vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline_layout, 0, 1, - &white_descriptor, 0, nullptr); + bind_material(white_descriptor); if (gpu_active && !gpu_frame.bins.empty()) { VkDeviceSize scene_offset{}; vkCmdBindVertexBuffers(command, 0, 1, &scene.vertices.handle, &scene_offset); @@ -2322,7 +2492,7 @@ struct Renderer::Impl { scene.graphics_pipeline); for (std::uint32_t bin = 0; bin < gpu_frame.bins.size(); ++bin) { auto descriptor = textures.at(gpu_frame.textures[bin]).descriptor; - const std::array sets{descriptor, + const std::array sets{descriptor, lighting_set, scene.graphics_main}; vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS, scene.graphics_pipeline_layout, 0, sets.size(), @@ -2346,8 +2516,7 @@ struct Renderer::Impl { } for (auto batch : scene_batches) { auto descriptor = textures.at(batch.texture).descriptor; - vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline_layout, - 0, 1, &descriptor, 0, nullptr); + bind_material(descriptor); vkCmdDraw(command, batch.count, 1, batch.first, 0); ++statistics.draw_calls; } @@ -2446,7 +2615,7 @@ struct Renderer::Impl { scene.graphics_pipeline); for (std::uint32_t bin = 0; bin < gpu_frame.bins.size(); ++bin) { auto descriptor = textures.at(gpu_frame.textures[bin]).descriptor; - const std::array sets{descriptor, + const std::array sets{descriptor, lighting_set, scene.graphics_post}; vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS, scene.graphics_pipeline_layout, 0, @@ -2618,7 +2787,10 @@ struct Renderer::Impl { ++statistics.frame; statistics.gpu_allocated_bytes = vertices.allocation_size + readback.allocation_size + color.allocation_size + depth.allocation_size + - shadow.allocation_size; + shadow.allocation_size + + lighting_header.allocation_size + + lighting_locals.allocation_size + + lighting_views.allocation_size; statistics.texture_count = static_cast(textures.size()); for (const auto& [_, texture] : textures) statistics.gpu_allocated_bytes += texture.image.allocation_size; diff --git a/src/render/shader_contract.cpp b/src/render/shader_contract.cpp index 3ccb943..7c2e6cc 100644 --- a/src/render/shader_contract.cpp +++ b/src/render/shader_contract.cpp @@ -34,13 +34,21 @@ void validate_layout(const Json& layout, std::string_view entry) { const bool fragment = entry == "fragmentMain"; require(layout.at("stage") == (fragment ? "fragment" : "vertex"), "shader stage changed"); const auto& descriptors = layout.at("descriptors"); - require(descriptors.is_array() && descriptors.size() == 4, "descriptor count changed"); + require(descriptors.is_array() && descriptors.size() == 8, "descriptor count changed"); for (std::size_t i = 0; i < descriptors.size(); ++i) { const auto& binding = descriptors[i]; - require(binding.at("set") == 0 && binding.at("binding") == i && binding.at("count") == 1, + const auto set = i < 4 ? 0 : 1; + const auto slot = i % 4; + require(binding.at("set") == set && binding.at("binding") == slot && + binding.at("count") == 1, "descriptor set, binding or array count changed"); - require(binding.at("type") == (i % 2 ? "sampler" : "sampled_image_2d"), + const auto* expected_type = set == 0 ? (slot % 2 ? "sampler" : "sampled_image_2d") + : slot == 3 ? "sampled_image_2d" : "storage_buffer"; + require(binding.at("type") == expected_type, "descriptor type changed"); + if (set == 1 && slot < 3) + require(binding.at("element_stride") == (slot == 2 ? 112 : 80), + "lighting storage record stride changed"); require(fragment || !binding.at("used").get(), "vertex texture bindings are unsupported"); } @@ -98,7 +106,7 @@ void validate_gpu_layout(const Json& layout, std::string_view entry) { const std::array graphics_strides{224, 4, 208}; for (std::size_t i = 0; i < expected_count; ++i) { const auto& binding = descriptors[i]; - require(binding.at("set") == (graphics ? 1 : 0) && binding.at("binding") == i && + require(binding.at("set") == (graphics ? 2 : 0) && binding.at("binding") == i && binding.at("count") == 1, "GPU descriptor set, binding or count changed"); const int stride = graphics ? graphics_strides[i] : hzb ? 0 : compute_strides[i]; diff --git a/tests/authoring_tests.cpp b/tests/authoring_tests.cpp index a300f35..024585a 100644 --- a/tests/authoring_tests.cpp +++ b/tests/authoring_tests.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #define CHECK(x) \ @@ -44,6 +45,13 @@ int main() { light_component["fields"]["range"] = 10; light_component["fields"]["intensity"] = -1; fails([&] { schemas.validate_component(light_component); }, "validation.minimum"); + light_component["fields"] = {{"kind", "spot"}, {"inner_angle", 0.9}}; + fails([&] { schemas.validate_component(light_component); }, "validation.light_cone"); + light_component["fields"] = {{"kind", "spot"}, + {"inner_angle", 0.2}, + {"outer_angle", 0.5}, + {"shadow_priority", std::int64_t{2147483648}}}; + fails([&] { schemas.validate_component(light_component); }, "validation.maximum"); AuthoringService service(root, schemas); auto created = service.create("Courtyard", 3); const std::string id = created["id"]; diff --git a/tests/render_gpu_shader_contract_tests.cpp b/tests/render_gpu_shader_contract_tests.cpp index 423dc93..82aabfc 100644 --- a/tests/render_gpu_shader_contract_tests.cpp +++ b/tests/render_gpu_shader_contract_tests.cpp @@ -59,6 +59,15 @@ int main() { faset::atomic_write_json(reflection_file, metadata); must_reject([&] { (void)faset::render::detail::load_gpu_shader_bundle(temporary); }, "A consistently rehashed but incompatible GPU record stride must be rejected"); + faset::atomic_write_json(reflection_file, + faset::read_json(original / "gpuPostCullMain.reflection.json")); + reflection_file = temporary / "gpuVertexMain.reflection.json"; + metadata = faset::read_json(original / "gpuVertexMain.reflection.json"); + metadata["layout"]["descriptors"][0]["set"] = 1; + metadata["layout_fingerprint"] = faset::sha256(metadata["layout"].dump()); + faset::atomic_write_json(reflection_file, metadata); + must_reject([&] { (void)faset::render::detail::load_gpu_shader_bundle(temporary); }, + "GPU graphics scene buffers must stay in descriptor set two"); fs::remove(temporary / "gpuHzbMain.spv"); must_reject([&] { (void)faset::render::detail::load_gpu_shader_bundle(temporary); }, "Missing P2 entry must be rejected"); diff --git a/tests/render_reload_tests.cpp b/tests/render_reload_tests.cpp index da9cade..59e0ae0 100644 --- a/tests/render_reload_tests.cpp +++ b/tests/render_reload_tests.cpp @@ -59,6 +59,27 @@ int main() { const auto original_reflection = read_text(bundle / "fragmentMain.reflection.json"); const auto original_fingerprint = Json::parse(original_reflection).at("layout_fingerprint"); render::validate_shader_bundle(bundle); + auto bad_lighting_stride = Json::parse(original_reflection); + auto& lighting_descriptors = bad_lighting_stride["layout"]["descriptors"]; + bool found_local_buffer = false; + for (auto& descriptor : lighting_descriptors) + if (descriptor["set"] == 1 && descriptor["binding"] == 1) { + descriptor["element_stride"] = 96; + found_local_buffer = true; + } + require(found_local_buffer, "Lighting stride fixture exists"); + bad_lighting_stride["layout_fingerprint"] = + sha256(bad_lighting_stride["layout"].dump()); + atomic_write_json(bundle / "fragmentMain.reflection.json", bad_lighting_stride); + bool rejected_lighting_stride = false; + try { + render::validate_shader_bundle(bundle); + } catch (const std::exception&) { + rejected_lighting_stride = true; + } + require(rejected_lighting_stride, + "Rehashed incompatible local-light element stride must be rejected"); + atomic_write(bundle / "fragmentMain.reflection.json", original_reflection); render::RendererConfig configuration; configuration.width = configuration.height = 64; configuration.headless = true; @@ -161,6 +182,18 @@ int main() { atomic_write(bundle / "fragmentMain.spv", "damaged bytecode"); retained(); restore(); + auto incompatible = original_source; + auto at = incompatible.find(" float4 reserved;"); + require(at != std::string::npos, "Local-light stride fixture exists"); + incompatible.replace(at, std::string(" float4 reserved;").size(), + " float4 reserved;\n float4 incompatibleExtraLane;"); + atomic_write(source, incompatible); + require(compile(source, bundle) == 0, "Compile incompatible light-buffer stride"); + require(read_json(bundle / "fragmentMain.reflection.json").at("layout_fingerprint") != + original_fingerprint, + "Lighting stride edit changes normalized layout fingerprint"); + retained(); + restore(); auto malformed = original_spirv; for (int i = 0; i < 4; ++i) malformed[20 + i] = 0; // zero-word SPIR-V instruction @@ -170,8 +203,8 @@ int main() { atomic_write_json(bundle / "fragmentMain.reflection.json", metadata); retained(); restore(); - auto incompatible = original_source; - auto at = incompatible.find("[[vk::binding(2,0)]]"); + incompatible = original_source; + at = incompatible.find("[[vk::binding(2,0)]]"); require(at != std::string::npos, "Shader descriptor fixture exists"); incompatible.replace(at, std::string("[[vk::binding(2,0)]]").size(), "[[vk::binding(7,0)]]"); diff --git a/tests/render_tests.cpp b/tests/render_tests.cpp index 3aaacc0..bb7be9e 100644 --- a/tests/render_tests.cpp +++ b/tests/render_tests.cpp @@ -134,6 +134,65 @@ int main(int argc, char** argv) { renderer.render(scene); pixels = renderer.pixels(); require(pixels[index + 2] > 220, "Texture revision upload"); + Snapshot two_lights; + two_lights.eye = {0, 0, 6}; + two_lights.projection = perspective(.85f, 320.f / 240.f, .1f, 30.f); + two_lights.view_projection = + multiply(two_lights.projection, look_at(two_lights.eye, {0, 0, 0})); + two_lights.authored_lights_present = true; + two_lights.draws.push_back( + {cube_mesh(), transform({-1.4f, 0, 0}), {.5f, .5f, .5f, 1}, .6f, 0, false}); + two_lights.draws.back().instance_key = "left-light-receiver"; + two_lights.draws.push_back( + {cube_mesh(), transform({1.4f, 0, 0}), {.5f, .5f, .5f, 1}, .6f, 0, false}); + two_lights.draws.back().instance_key = "right-light-receiver"; + two_lights.ui_quads.push_back({8, 8, 40, 20, {.8f, .1f, .15f, 1}}); + for (auto mode : {VisibilityMode::Direct, VisibilityMode::GpuFrustum}) { + renderer.set_visibility_mode(mode); + renderer.render(two_lights); + const auto dark = renderer.pixels(); + require(renderer.stats().validation_errors == 0, + "Zero-local-light descriptors are initialized"); + auto legacy_lights = two_lights; + legacy_lights.authored_lights_present = false; + renderer.render(legacy_lights); + const auto legacy = renderer.pixels(); + const auto left = (120 * 320 + 99) * 4; + require(legacy[left] > dark[left] + 15, + "Authored-light presence suppresses the legacy sun even without a local light"); + two_lights.local_lights = { + {LocalLight::Kind::Point, "red", {-1.4f, 0, 1.4f}, {0, 0, -1}, + {1, 0, 0, 1}, 8, 2.2f, .35f, .7f, false, 0}, + {LocalLight::Kind::Point, "blue", {1.4f, 0, 1.4f}, {0, 0, -1}, + {0, 0, 1, 1}, 8, 2.5f, .35f, .7f, false, 0}}; + renderer.render(two_lights); + const auto lit = renderer.pixels(); + const auto right = (120 * 320 + 221) * 4; + const auto ui = (10 * 320 + 10) * 4; + require(lit[left] > dark[left] + 20 && lit[right + 2] > dark[right + 2] + 20, + "Separated red and blue point lights illuminate their receivers"); + require(std::abs(int(lit[left + 2]) - int(dark[left + 2])) < 6 && + std::abs(int(lit[right]) - int(dark[right])) < 6, + "Local light range keeps the opposite colored light off each receiver"); + for (int channel = 0; channel < 4; ++channel) + require(lit[ui + channel] == dark[ui + channel], + "Lighting changes leave UI tint unchanged"); + require(renderer.stats().validation_errors == 0, + "Direct and GPU local lighting report no Vulkan errors"); + if (mode == VisibilityMode::GpuFrustum) + require(renderer.stats().effective_visibility_mode == VisibilityMode::GpuFrustum, + "Local light image test actually exercises the GPU visibility path"); + two_lights.local_lights[1].kind = LocalLight::Kind::Spot; + renderer.render(two_lights); + const auto aimed = renderer.pixels(); + two_lights.local_lights[1].direction = {1, 0, 0}; + renderer.render(two_lights); + const auto turned = renderer.pixels(); + require(aimed[right + 2] > turned[right + 2] + 20, + "Spotlight cone direction changes receiver illumination"); + two_lights.local_lights.clear(); + } + renderer.set_visibility_mode(VisibilityMode::Direct); if (argc > 2) renderer.capture(argv[2]); renderer.resize(400, 300); diff --git a/tests/runtime_player_tests.cpp b/tests/runtime_player_tests.cpp index 2d94349..e36e7bc 100644 --- a/tests/runtime_player_tests.cpp +++ b/tests/runtime_player_tests.cpp @@ -134,6 +134,18 @@ void lightingExtraction(faset::player::SceneView& view) { rejectsContaining([&] { view.build(scene, 1); }, "bad-light", "inner_angle"); scene["entities"][0]["components"][0]["fields"] = {{"kind", "area"}}; rejectsContaining([&] { view.build(scene, 1); }, "bad-light", "kind"); + scene["entities"][0]["components"][0]["fields"] = + {{"kind", "point"}, {"shadow_priority", std::int64_t{2147483648}}}; + rejectsContaining([&] { view.build(scene, 1); }, "bad-light", "shadow_priority"); + scene["entities"][0]["components"][0]["fields"] = {{"kind", "point"}}; + scene["entities"][0]["components"].insert( + scene["entities"][0]["components"].begin(), + component("faset.transform", {{"scale", {1, 1, 0}}})); + const auto flatPoint = view.build(scene, 1); + check(flatPoint.local_lights.size() == 1 && + flatPoint.local_lights[0].kind == faset::render::LocalLight::Kind::Point, + "Point light accepts a zero Z scale because it needs only a position"); + scene["entities"][0]["components"].erase(scene["entities"][0]["components"].begin()); scene["entities"][0]["components"][0]["fields"] = {{"kind", "point"}, {"color", Json::array({1, std::numeric_limits::quiet_NaN(), 1, 1})}}; diff --git a/tests/test_shader_reflection.py b/tests/test_shader_reflection.py index 3a81afb..38f8bb3 100644 --- a/tests/test_shader_reflection.py +++ b/tests/test_shader_reflection.py @@ -36,6 +36,35 @@ def parameter(name: str, index: int, shape: str, access: str, stride: int | None class ReflectionTests(unittest.TestCase): + def test_graphics_lighting_abi(self): + compiler = os.environ["FASET_TEST_SLANGC"] + with tempfile.TemporaryDirectory(prefix="faset-lighting-abi-") as directory: + for source, entry, defines in ( + ("baseline.slang", "fragmentMain", []), + ("gpu_scene.slang", "gpuVertexMain", ["--define", "FASET_GPU_GRAPHICS=1"]), + ): + process = subprocess.run( + [sys.executable, str(SCRIPT), "--compiler", compiler, "--source", + str(SCRIPT.parents[1] / "shaders" / source), "--entry", entry, + *defines, "--output", directory], + capture_output=True, text=True, + ) + self.assertEqual(process.returncode, 0, process.stderr) + fragment = json.loads((Path(directory) / "fragmentMain.reflection.json").read_text()) + gpu_vertex = json.loads((Path(directory) / "gpuVertexMain.reflection.json").read_text()) + lighting = { + (d["set"], d["binding"]): (d["type"], d.get("element_stride")) + for d in fragment["layout"]["descriptors"] + } + self.assertEqual([lighting[1, i] for i in range(4)], + [("storage_buffer", 80), ("storage_buffer", 80), + ("storage_buffer", 112), ("sampled_image_2d", None)]) + graphics = { + (d["set"], d["binding"]): d["element_stride"] + for d in gpu_vertex["layout"]["descriptors"] + } + self.assertEqual([graphics[2, i] for i in range(3)], [224, 4, 208]) + def test_gpu_vertex_paths_do_not_require_shader_draw_parameters(self): # SV_InstanceID makes Slang subtract BaseInstance and emit DrawParameters. # Our indirect commands always use firstInstance=0, so the Vulkan instance