Merge commit 'b191ae0' into feat/p1-p3-integration
Native and manual checks / native (ubuntu-24.04) (push) Failing after 31s
Windows editor and software Vulkan / windows-graphics (push) Canceled after 0s
Native and manual checks / manual (push) Successful in 28s
Native and manual checks / native (windows-2025) (push) Canceled after 0s

This commit is contained in:
Emil
2026-09-24 03:01:51 +03:00
28 changed files with 3042 additions and 121 deletions
+28 -3
View File
@@ -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<double>() <=
effective.at("outer_angle").get<double>(),
"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) &&
@@ -252,9 +261,25 @@ SchemaRegistry builtin_schemas() {
{{"fov", Json{{"type", "number"}, {"default", 60.0}, {"min", 1.0}, {"max", 179.0}}},
{"near", Json{{"type", "number"}, {"default", 0.1}, {"min", 0.001}}},
{"far", Json{{"type", "number"}, {"default", 1000.0}, {"min", 0.01}}}});
add("faset.light", "Directional Light",
{{"color", field("color", {1, 1, 1, 1})},
{"intensity", Json{{"type", "number"}, {"default", 1.0}, {"min", 0.0}}}});
add("faset.light", "Light",
{{"kind", Json{{"type", "string"},
{"default", "directional"},
{"enum", {"directional", "point", "spot"}}}},
{"enabled", field("boolean", true)},
{"color", field("color", {1, 1, 1, 1})},
{"intensity", Json{{"type", "number"}, {"default", 1.0}, {"min", 0.0}}},
{"range", Json{{"type", "number"}, {"default", 10.0}, {"min", 0.001}}},
{"inner_angle", Json{{"type", "number"},
{"default", 0.35}, {"min", 0.0}, {"max", 1.55},
{"unit", "radians"}}},
{"outer_angle", Json{{"type", "number"},
{"default", 0.7}, {"min", 0.001}, {"max", 1.55},
{"unit", "radians"}}},
{"casts_shadow", field("boolean", true)},
{"shadow_priority", Json{{"type", "integer"},
{"default", 0},
{"min", std::numeric_limits<int>::min()},
{"max", std::numeric_limits<int>::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};
+21
View File
@@ -381,6 +381,27 @@ void DebugOverlay::append(render::Snapshot& output, render::Renderer& renderer,
ImGui::Text("Prepared LOD: %u / %u / %u / %u+", stats.lod_counts[0],
stats.lod_counts[1], stats.lod_counts[2], stats.lod_counts[3]);
ImGui::Separator();
ImGui::TextUnformatted("Lighting and shadows");
ImGui::Text("Lighting path: %s", stats.effective_lighting_path.c_str());
ImGui::Text("Local lights: %u submitted, %u omitted",
stats.submitted_local_lights, stats.omitted_local_lights);
ImGui::Text("Sun cascades: %u / %u effective",
stats.requested_sun_cascades, stats.effective_sun_cascades);
ImGui::Text("Local faces: %u requested, %u rasterized (%u tiles)",
stats.requested_local_shadow_faces, stats.local_shadow_faces,
stats.local_shadow_tiles);
ImGui::Text("Dropped faces: %u (point %u, atlas %u, draw budget %u, unavailable %u)",
stats.dropped_shadow_faces, stats.dropped_point_shadow_faces,
stats.shadow_atlas_full_drops, stats.shadow_caster_budget_drops,
stats.shadow_unavailable_drops);
ImGui::Text("Shadow caster draws: %u / 4096", stats.shadow_caster_draws);
ImGui::Text("Atlas memory: sun %.1f MiB, local %.1f MiB",
double(stats.sun_shadow_atlas_bytes) / 1048576.0,
double(stats.local_shadow_atlas_bytes) / 1048576.0);
if (stats.gpu_ms > 0)
ImGui::Text("Shadow GPU: sun %.2f ms, local %.2f ms",
stats.gpu_sun_shadow_ms, stats.gpu_local_shadow_ms);
ImGui::Separator();
ImGui::Text("Vulkan allocations: %.2f MiB",
double(stats.gpu_allocated_bytes) / 1048576.0);
ImGui::Text("Validation: %s Errors: %u",
+129 -5
View File
@@ -61,6 +61,16 @@ render::Vec3 direction(const render::Mat4& m, render::Vec3 p) {
return {m[0] * p[0] + m[4] * p[1] + m[8] * p[2], m[1] * p[0] + m[5] * p[1] + m[9] * p[2],
m[2] * p[0] + m[6] * p[1] + m[10] * p[2]};
}
render::Vec3 normalized(render::Vec3 value, const std::string& entityId,
std::string_view field) {
const auto length = std::hypot(value[0], value[1], value[2]);
if (!std::isfinite(length) || length < 1e-6f)
throw std::invalid_argument("Light on entity " + entityId + " has invalid " +
std::string(field));
for (auto& axis : value)
axis /= length;
return value;
}
std::pair<std::string, std::string> reference(const std::string& ref) {
const auto hash = ref.find('#');
return {ref.substr(0, hash), hash == std::string::npos ? std::string{} : ref.substr(hash + 1)};
@@ -263,8 +273,12 @@ render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSetting
const auto id = entity.at("id").get<std::string>();
if (!byId.emplace(id, &entity).second)
throw std::invalid_argument("Duplicate scene ID");
if (!entity.contains("components") && entity.contains("light"))
out.authored_lights_present = true;
for (const auto& component : entity.value("components", Json::array())) {
const auto type = component.at("type").get<std::string>();
if (type == "faset.light")
out.authored_lights_present = true;
if (component.value("version", 1) != 1 &&
(type == "faset.transform" || type == "faset.sprite" || type == "faset.mesh" ||
type == "faset.camera" || type == "faset.light"))
@@ -301,8 +315,15 @@ render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSetting
render::Vec3 cameraUp{0, 1, 0};
bool foundCamera = false;
std::vector<std::pair<int, render::Sprite>> sprites;
std::vector<render::SunLight> directionalLights;
for (const auto& entity : entities) {
const auto model = world(world, entity);
const auto id = entity.at("id").get<std::string>();
render::Mat4 model;
try {
model = world(world, entity);
} catch (const std::exception& error) {
throw std::invalid_argument("Entity " + id + " transform: " + error.what());
}
if (auto fields = properties(entity, "camera");
!fields.is_null() && !camera.overrideSceneCamera && !foundCamera) {
camera.eye = point(model, {0, 0, 0});
@@ -314,8 +335,97 @@ render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSetting
foundCamera = true;
camera_id = entity.at("id").get<std::string>();
}
if (auto fields = properties(entity, "light"); !fields.is_null())
out.light_direction = direction(model, {-0.5f, -1, -0.3f});
if (auto fields = properties(entity, "light"); !fields.is_null()) {
auto invalid = [&](std::string_view field) -> void {
throw std::invalid_argument("Light on entity " + id + " has invalid " +
std::string(field));
};
for (const auto entry : model)
if (!std::isfinite(entry))
invalid("transform");
auto number = [&](const char* field, float fallback) {
if (!fields.contains(field))
return fallback;
const auto& value = fields.at(field);
if (!value.is_number())
invalid(field);
const auto decimal = value.get<double>();
if (!std::isfinite(decimal) ||
std::abs(decimal) > std::numeric_limits<float>::max())
invalid(field);
return static_cast<float>(decimal);
};
auto boolean = [&](const char* field, bool fallback) {
if (!fields.contains(field))
return fallback;
if (!fields.at(field).is_boolean())
invalid(field);
return fields.at(field).get<bool>();
};
const auto enabled = boolean("enabled", true);
if (enabled) {
if (fields.contains("kind") && !fields.at("kind").is_string())
invalid("kind");
const auto kind = fields.value("kind", std::string("directional"));
if (kind != "directional" && kind != "point" && kind != "spot")
invalid("kind");
render::Color color;
try {
color = vec<4>(fields, "color", {1, 1, 1, 1});
} catch (const std::exception&) {
invalid("color");
}
for (const auto channel : color)
if (channel < 0)
invalid("color");
const auto intensity = number("intensity", 1);
if (intensity < 0)
invalid("intensity");
const auto castsShadow = boolean("casts_shadow", true);
const auto stableId = id;
if (kind == "directional") {
directionalLights.push_back({stableId,
normalized(direction(model, {-0.5f, -1, -0.3f}), id,
"direction"),
color, intensity, castsShadow});
} else {
render::LocalLight local;
local.kind = kind == "point" ? render::LocalLight::Kind::Point
: render::LocalLight::Kind::Spot;
local.stable_id = stableId;
local.position = point(model, {0, 0, 0});
for (const auto coordinate : local.position)
if (!std::isfinite(coordinate))
invalid("position");
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);
if (local.range <= 0)
invalid("range");
local.inner_angle = number("inner_angle", 0.35f);
local.outer_angle = number("outer_angle", 0.7f);
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<float> / 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<double>();
if (priority < std::numeric_limits<int>::min() ||
priority > std::numeric_limits<int>::max())
invalid("shadow_priority");
local.shadow_priority = fields.at("shadow_priority").get<int>();
}
out.local_lights.push_back(std::move(local));
}
}
}
if (auto fields = properties(entity, "sprite"); !fields.is_null()) {
render::Sprite sprite;
sprite.layer = fields.value("layer", 0);
@@ -372,6 +482,18 @@ render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSetting
[](const auto& a, const auto& b) { return a.first < b.first; });
for (auto& pair : sprites)
out.sprites.push_back(std::move(pair.second));
std::sort(directionalLights.begin(), directionalLights.end(),
[](const auto& a, const auto& b) { return a.stable_id < b.stable_id; });
std::sort(out.local_lights.begin(), out.local_lights.end(),
[](const auto& a, const auto& b) { return a.stable_id < b.stable_id; });
if (!directionalLights.empty()) {
out.sun = directionalLights.front();
out.light_direction = out.sun->direction;
if (directionalLights.size() > 1)
impl_->messages.push_back("warning: multiple enabled directional lights; using " +
out.sun->stable_id + " and ignoring " +
std::to_string(directionalLights.size() - 1) + " others");
}
if (dimension == 2) {
const float height = camera.orthographicHeight;
if (!std::isfinite(height) || height <= 0)
@@ -408,8 +530,10 @@ render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSetting
out.projection = render::perspective(
camera.verticalFovDegrees * std::numbers::pi_v<float> / 180, aspect,
camera.nearPlane, camera.farPlane);
out.view_projection = render::multiply(
out.projection, render::look_at(camera.eye, camera.target, cameraUp));
const auto view = render::look_at(camera.eye, camera.target, cameraUp);
out.view_projection = render::multiply(out.projection, view);
out.camera_frustum = render::CameraFrustum{view, out.projection, camera.nearPlane,
camera.farPlane, true};
}
std::sort(impl_->messages.begin(), impl_->messages.end());
impl_->messages.erase(std::unique(impl_->messages.begin(), impl_->messages.end()),
+394
View File
@@ -0,0 +1,394 @@
#include <faset/render/lighting.hpp>
#include <algorithm>
#include <array>
#include <cmath>
#include <limits>
#include <numbers>
#include <stdexcept>
#include <unordered_set>
namespace faset::render {
namespace {
Vec3 add(Vec3 a, Vec3 b) { return {a[0] + b[0], a[1] + b[1], a[2] + b[2]}; }
Vec3 subtract(Vec3 a, Vec3 b) { return {a[0] - b[0], a[1] - b[1], a[2] - b[2]}; }
Vec3 scale(Vec3 a, float factor) { return {a[0] * factor, a[1] * factor, a[2] * factor}; }
float dot(Vec3 a, Vec3 b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
float length(Vec3 a) { return std::sqrt(dot(a, a)); }
Vec3 unit(Vec3 a) {
const float magnitude = length(a);
if (!std::isfinite(magnitude) || magnitude < 1e-6f)
throw std::invalid_argument("Shadow light direction must be finite and nonzero");
return scale(a, 1.f / magnitude);
}
Vec3 project(const Mat4& matrix, Vec3 value) {
return {matrix[0] * value[0] + matrix[4] * value[1] + matrix[8] * value[2] + matrix[12],
matrix[1] * value[0] + matrix[5] * value[1] + matrix[9] * value[2] + matrix[13],
matrix[2] * value[0] + matrix[6] * value[1] + matrix[10] * value[2] + matrix[14]};
}
std::array<float, 4> clip(const Mat4& matrix, Vec3 value) {
return {matrix[0] * value[0] + matrix[4] * value[1] + matrix[8] * value[2] + matrix[12],
matrix[1] * value[0] + matrix[5] * value[1] + matrix[9] * value[2] + matrix[13],
matrix[2] * value[0] + matrix[6] * value[1] + matrix[10] * value[2] + matrix[14],
matrix[3] * value[0] + matrix[7] * value[1] + matrix[11] * value[2] + matrix[15]};
}
std::array<Vec3, 8> corners(const Bounds& bounds) {
std::array<Vec3, 8> result{};
for (unsigned i = 0; i < 8; ++i)
result[i] = {i & 1 ? bounds.max[0] : bounds.min[0],
i & 2 ? bounds.max[1] : bounds.min[1],
i & 4 ? bounds.max[2] : bounds.min[2]};
return result;
}
Vec3 camera_to_world(const Mat4& view, Vec3 camera) {
// CameraFrustum::view is an unscaled, orthonormal look_at matrix.
return {view[0] * (camera[0] - view[12]) + view[1] * (camera[1] - view[13]) +
view[2] * (camera[2] - view[14]),
view[4] * (camera[0] - view[12]) + view[5] * (camera[1] - view[13]) +
view[6] * (camera[2] - view[14]),
view[8] * (camera[0] - view[12]) + view[9] * (camera[1] - view[13]) +
view[10] * (camera[2] - view[14])};
}
std::array<Vec3, 8> frustum_slice(const CameraFrustum& camera, float near_distance,
float far_distance) {
std::array<Vec3, 8> result{};
for (unsigned i = 0; i < 8; ++i) {
const float distance = i & 4 ? far_distance : near_distance;
const float x = i & 1 ? 1.f : -1.f;
const float y = i & 2 ? 1.f : -1.f;
Vec3 local{};
if (camera.perspective)
local = {x * distance / camera.projection[0],
y * distance / camera.projection[5], -distance};
else
local = {(x - camera.projection[12]) / camera.projection[0],
(y - camera.projection[13]) / camera.projection[5], -distance};
result[i] = camera_to_world(camera.view, local);
}
return result;
}
bool overlaps_xy(const Bounds& bounds, const Mat4& light_view, float left, float right,
float bottom, float top) {
float min_x = std::numeric_limits<float>::infinity();
float max_x = -min_x, min_y = min_x, max_y = -min_x;
for (const auto point : corners(bounds)) {
const auto light = project(light_view, point);
min_x = std::min(min_x, light[0]);
max_x = std::max(max_x, light[0]);
min_y = std::min(min_y, light[1]);
max_y = std::max(max_y, light[1]);
}
return max_x >= left && min_x <= right && max_y >= bottom && min_y <= top;
}
bool intersects_frustum(const Bounds& bounds, const Mat4& view_projection) {
std::array<unsigned, 7> rejected{};
for (const auto point : corners(bounds)) {
const auto p = clip(view_projection, point);
if (!std::all_of(p.begin(), p.end(), [](float value) { return std::isfinite(value); }))
return true; // Invalid projection fails open so no caster is lost silently.
rejected[0] += p[0] < -p[3];
rejected[1] += p[0] > p[3];
rejected[2] += p[1] < -p[3];
rejected[3] += p[1] > p[3];
rejected[4] += p[2] < 0;
rejected[5] += p[2] > p[3];
rejected[6] += p[3] <= 0;
}
return std::none_of(rejected.begin(), rejected.end(), [](unsigned count) { return count == 8; });
}
void tile(ShadowView& view, std::uint32_t atlas_size, std::uint32_t tiles_across,
std::uint32_t index) {
constexpr std::uint32_t guard = 2;
const auto size = atlas_size / tiles_across;
view.tile_index = index;
view.tile_origin_x = (index % tiles_across) * size;
view.tile_origin_y = (index / tiles_across) * size;
view.tile_size = size;
view.usable_size = size - 2 * guard;
const auto reciprocal = 1.f / float(atlas_size);
view.atlas_scale_offset = {float(view.usable_size) * reciprocal,
float(view.usable_size) * reciprocal,
float(view.tile_origin_x + guard) * reciprocal,
float(view.tile_origin_y + guard) * reciprocal};
view.guarded_clamp = {(float(view.tile_origin_x + guard) + 1.5f) * reciprocal,
(float(view.tile_origin_y + guard) + 1.5f) * reciprocal,
(float(view.tile_origin_x + size - guard) - 1.5f) * reciprocal,
(float(view.tile_origin_y + size - guard) - 1.5f) * reciprocal};
}
std::vector<std::uint32_t> visible_casters(const Mat4& view_projection,
std::span<const ShadowCasterBounds> casters) {
std::vector<std::uint32_t> result;
for (const auto& caster : casters)
if (intersects_frustum(caster.world, view_projection))
result.push_back(caster.draw_index);
std::sort(result.begin(), result.end());
result.erase(std::unique(result.begin(), result.end()), result.end());
return result;
}
bool supported_atlas(std::uint32_t size, bool available) {
return available && (size == 2048 || size == 1024);
}
void validate_local(const LocalLight& light) {
const auto invalid = [&](const char* field) {
throw std::invalid_argument("Local light " + light.stable_id + " has invalid " + field);
};
if (light.stable_id.empty())
invalid("stable_id");
if (!std::all_of(light.position.begin(), light.position.end(),
[](float v) { return std::isfinite(v); }))
invalid("position");
if (!std::all_of(light.color.begin(), light.color.end(),
[](float v) { return std::isfinite(v) && v >= 0; }))
invalid("color");
if (!std::isfinite(light.intensity) || light.intensity < 0)
invalid("intensity");
if (!std::isfinite(light.range) || light.range <= 0)
invalid("range");
if (light.kind == LocalLight::Kind::Spot) {
if (!std::isfinite(light.inner_angle) || !std::isfinite(light.outer_angle) ||
light.inner_angle < 0 || light.inner_angle > light.outer_angle ||
light.outer_angle >= std::numbers::pi_v<float> / 2 || light.outer_angle <= 0)
invalid("inner_angle/outer_angle");
if (!std::all_of(light.direction.begin(), light.direction.end(),
[](float v) { return std::isfinite(v); }) ||
length(light.direction) < 1e-6f)
invalid("direction");
}
}
float projected_influence(const LocalLight& light, const Snapshot& frame) {
const auto distance = length(subtract(light.position, frame.eye));
const auto projection_scale =
std::max(std::abs(frame.projection[0]), std::abs(frame.projection[5]));
return light.range * projection_scale / std::max(distance, .1f);
}
ShadowView sun_view(const Snapshot& frame, const SunLight& sun,
std::span<const ShadowCasterBounds> casters, float split_near,
float split_far, std::uint32_t index, std::uint32_t atlas_size) {
ShadowView result;
result.kind = ShadowView::Kind::Sun;
result.light_id = sun.stable_id;
result.face_index = index;
result.split_near = split_near;
result.split_far = split_far;
tile(result, atlas_size, 2, index);
const auto direction = unit(sun.direction);
const auto up = std::abs(direction[1]) > .98f ? Vec3{0, 0, 1} : Vec3{0, 1, 0};
const auto light_origin_view = look_at({0, 0, 0}, direction, up);
if (!frame.camera_frustum) {
const auto light_eye = scale(direction, -30);
result.view_projection = multiply(orthographic(-20, 20, -20, 20, .1f, 80),
look_at(light_eye, {0, 0, 0}, up));
result.caster_indices = visible_casters(result.view_projection, casters);
return result;
}
const auto receivers = frustum_slice(*frame.camera_frustum, split_near, split_far);
Vec3 center{};
for (const auto corner : receivers)
center = add(center, scale(corner, 1.f / 8));
float radius{};
for (const auto corner : receivers)
radius = std::max(radius, length(subtract(corner, center)));
radius = std::max(.25f, std::ceil(radius * 16.f) / 16.f);
const auto center_light = project(light_origin_view, center);
const auto texel = (2 * radius) / float(result.usable_size);
result.snapped_center_x = std::round(center_light[0] / texel) * texel;
result.snapped_center_y = std::round(center_light[1] / texel) * texel;
const float left = result.snapped_center_x - radius;
const float right = result.snapped_center_x + radius;
const float bottom = result.snapped_center_y - radius;
const float top = result.snapped_center_y + radius;
float nearest_ray = std::numeric_limits<float>::infinity();
float furthest_ray = -nearest_ray;
for (const auto corner : receivers) {
const auto ray = dot(corner, direction);
nearest_ray = std::min(nearest_ray, ray);
furthest_ray = std::max(furthest_ray, ray);
}
for (const auto& caster : casters) {
if (!overlaps_xy(caster.world, light_origin_view, left, right, bottom, top))
continue;
result.caster_indices.push_back(caster.draw_index);
for (const auto corner : corners(caster.world)) {
const auto ray = dot(corner, direction);
nearest_ray = std::min(nearest_ray, ray);
furthest_ray = std::max(furthest_ray, ray);
}
}
std::sort(result.caster_indices.begin(), result.caster_indices.end());
result.caster_indices.erase(
std::unique(result.caster_indices.begin(), result.caster_indices.end()),
result.caster_indices.end());
const auto light_eye = scale(direction, nearest_ray - 1.f);
const auto view = look_at(light_eye, add(light_eye, direction), up);
const auto depth = std::max(2.f, furthest_ray - nearest_ray + 2.f);
result.view_projection = multiply(orthographic(left, right, bottom, top, .1f, depth),
view);
return result;
}
ShadowView local_view(const LocalLight& light, std::uint32_t face,
std::span<const ShadowCasterBounds> casters) {
ShadowView result;
result.kind = light.kind == LocalLight::Kind::Point ? ShadowView::Kind::Point
: ShadowView::Kind::Spot;
result.light_id = light.stable_id;
result.face_index = face;
static constexpr std::array<Vec3, 6> axes{{{1, 0, 0}, {-1, 0, 0}, {0, 1, 0},
{0, -1, 0}, {0, 0, 1}, {0, 0, -1}}};
static constexpr std::array<Vec3, 6> ups{{{0, -1, 0}, {0, -1, 0}, {0, 0, 1},
{0, 0, -1}, {0, -1, 0}, {0, -1, 0}}};
const auto direction = light.kind == LocalLight::Kind::Point ? axes.at(face)
: unit(light.direction);
const auto up = light.kind == LocalLight::Kind::Point ? ups.at(face)
: std::abs(direction[1]) > .98f ? Vec3{0, 0, 1} : Vec3{0, 1, 0};
const auto near_plane = std::max(.0001f, std::min(.05f, light.range * .1f));
// Slight face overlap keeps the dominant-axis choice inside both adjacent
// projections at a cubemap seam; the guarded tile still prevents PCF bleed.
const auto fov = light.kind == LocalLight::Kind::Point
? std::numbers::pi_v<float> / 2 + .04f : light.outer_angle * 2;
result.view_projection = multiply(
perspective(fov, 1, near_plane, light.range),
look_at(light.position, add(light.position, direction), up));
result.caster_indices = visible_casters(result.view_projection, casters);
return result;
}
} // namespace
ShadowPlan build_shadow_plan(const Snapshot& frame,
std::span<const ShadowCasterBounds> casters,
const ShadowBudget& budget) {
for (const auto& caster : casters)
for (int axis = 0; axis < 3; ++axis)
if (!std::isfinite(caster.world.min[axis]) ||
!std::isfinite(caster.world.max[axis]) ||
caster.world.min[axis] > caster.world.max[axis])
throw std::invalid_argument("Shadow caster world bounds must be finite and ordered");
ShadowPlan plan;
plan.sun_atlas_size = supported_atlas(budget.sun_atlas_size, budget.sun_atlas_available)
? budget.sun_atlas_size : 0;
plan.local_atlas_size = supported_atlas(budget.local_atlas_size, budget.local_atlas_available)
? budget.local_atlas_size : 0;
std::unordered_set<std::string> ids;
std::vector<std::pair<std::size_t, float>> ranked;
ranked.reserve(frame.local_lights.size());
for (std::size_t i = 0; i < frame.local_lights.size(); ++i) {
const auto& light = frame.local_lights[i];
validate_local(light); // Validate overflow records too, before truncation.
if (!ids.insert(light.stable_id).second)
throw std::invalid_argument("Duplicate local light stable_id: " + light.stable_id);
ranked.emplace_back(i, projected_influence(light, frame));
}
std::sort(ranked.begin(), ranked.end(), [&](const auto& a, const auto& b) {
const auto& left = frame.local_lights[a.first];
const auto& right = frame.local_lights[b.first];
if (left.shadow_priority != right.shadow_priority)
return left.shadow_priority > right.shadow_priority;
if (a.second != b.second)
return a.second > b.second;
return left.stable_id < right.stable_id;
});
const auto selected = std::min<std::size_t>(ranked.size(),
std::min(budget.max_local_lights, 128u));
plan.omitted_local_lights = static_cast<std::uint32_t>(ranked.size() - selected);
for (std::size_t i = 0; i < selected; ++i)
plan.submitted_local_indices.push_back(ranked[i].first);
std::optional<SunLight> sun = frame.sun;
if (!sun && !frame.authored_lights_present && frame.local_lights.empty())
sun = SunLight{"legacy-sun", frame.light_direction, {1, 1, 1, 1}, 1, true};
if (sun && sun->casts_shadow) {
plan.requested_sun_cascades = frame.camera_frustum
? std::min(4u, budget.max_sun_views) : 1u;
if (frame.camera_frustum &&
(frame.camera_frustum->near_plane <= 0 ||
frame.camera_frustum->far_plane <= frame.camera_frustum->near_plane ||
frame.camera_frustum->projection[0] == 0 ||
frame.camera_frustum->projection[5] == 0))
throw std::invalid_argument("Shadow camera frustum is invalid");
const float near_plane = frame.camera_frustum ? frame.camera_frustum->near_plane : .1f;
const float far_plane = frame.camera_frustum
? std::min(frame.camera_frustum->far_plane, budget.max_shadow_distance) : 80.f;
if (far_plane <= near_plane)
throw std::invalid_argument("Shadow distance does not reach the camera near plane");
float previous = near_plane;
for (std::uint32_t i = 0; i < plan.requested_sun_cascades; ++i) {
const auto ratio = float(i + 1) / float(plan.requested_sun_cascades);
const auto logarithmic = near_plane * std::pow(far_plane / near_plane, ratio);
const auto uniform = near_plane + (far_plane - near_plane) * ratio;
const auto split = i + 1 == plan.requested_sun_cascades
? far_plane : .5f * (logarithmic + uniform);
ShadowView view;
if (plan.sun_atlas_size)
view = sun_view(frame, *sun, casters, previous, split, i, plan.sun_atlas_size);
else {
view.kind = ShadowView::Kind::Sun;
view.light_id = sun->stable_id;
view.face_index = i;
view.split_near = previous;
view.split_far = split;
view.reason = ShadowDropReason::Unavailable;
}
if (plan.sun_atlas_size &&
view.caster_indices.size() <=
budget.max_caster_draws - std::min(plan.caster_draws, budget.max_caster_draws)) {
view.valid = true;
plan.caster_draws += static_cast<std::uint32_t>(view.caster_indices.size());
++plan.effective_sun_cascades;
} else {
if (plan.sun_atlas_size)
view.reason = ShadowDropReason::CasterBudget;
view.caster_indices.clear();
++plan.dropped_sun_views;
}
plan.sun_views.push_back(std::move(view));
previous = split;
}
}
for (const auto source : plan.submitted_local_indices) {
const auto& light = frame.local_lights[source];
LocalShadowAssignment assignment;
assignment.source_index = source;
assignment.first_view = static_cast<std::uint32_t>(plan.local_views.size());
const auto faces = light.kind == LocalLight::Kind::Point ? 6u : 1u;
if (!light.casts_shadow || light.intensity == 0) {
plan.local_assignments.push_back(assignment);
continue;
}
plan.local_faces_requested += faces;
if (!plan.local_atlas_size)
assignment.reason = ShadowDropReason::Unavailable;
else if (const auto capacity = std::min(budget.max_local_faces, 16u);
faces > capacity - std::min(plan.local_faces_used, capacity))
assignment.reason = ShadowDropReason::TileBudget;
else {
std::vector<ShadowView> group;
std::uint32_t group_draws{};
for (std::uint32_t face = 0; face < faces; ++face) {
auto view = local_view(light, face, casters);
tile(view, plan.local_atlas_size, 4,
plan.local_faces_used + face);
group_draws += static_cast<std::uint32_t>(view.caster_indices.size());
group.push_back(std::move(view));
}
if (group_draws > budget.max_caster_draws -
std::min(plan.caster_draws, budget.max_caster_draws))
assignment.reason = ShadowDropReason::CasterBudget;
else {
assignment.valid = true;
assignment.face_count = faces;
plan.local_faces_used += faces;
plan.caster_draws += group_draws;
for (auto& view : group) {
view.valid = true;
plan.local_views.push_back(std::move(view));
}
}
}
if (!assignment.valid) {
plan.dropped_local_faces += faces;
if (light.kind == LocalLight::Kind::Point)
plan.dropped_point_faces += 6;
}
plan.local_assignments.push_back(assignment);
}
return plan;
}
} // namespace faset::render
+460 -77
View File
@@ -6,14 +6,17 @@
#include <bit>
#include <chrono>
#include <cmath>
#include <cstddef>
#include <cstring>
#include <faset/core/io.hpp>
#include <faset/render/render_graph.hpp>
#include <faset/render/lighting.hpp>
#include <faset/render/renderer.hpp>
#include <faset/render/visibility.hpp>
#include <fstream>
#include <iostream>
#include <limits>
#include <numbers>
#include <optional>
#include <stdexcept>
#include <unordered_map>
@@ -72,6 +75,40 @@ struct ScenePush {
std::array<std::uint32_t, 4> draw_info;
};
static_assert(sizeof(ScenePush) == 112);
struct LightingHeaderGpu {
std::array<std::uint32_t, 4> counts{};
std::array<float, 4> sun_direction_intensity{};
std::array<float, 4> sun_color{};
std::array<float, 4> camera_forward_shadow_distance{};
std::array<float, 4> cascade_splits{};
};
struct LocalLightGpu {
std::array<float, 4> position_range{};
std::array<float, 4> direction_cos_outer{};
std::array<float, 4> color_intensity{};
std::array<float, 4> cone_type_shadow_view{};
std::array<float, 4> reserved{};
};
struct ShadowViewGpu {
Mat4 view_projection{identity};
std::array<float, 4> tile_scale_offset{};
std::array<float, 4> guarded_clamp{};
std::array<float, 4> 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<float, 4> point(const Mat4& m, std::array<float, 4> p) {
std::array<float, 4> o{};
for (int r = 0; r < 4; ++r)
@@ -164,7 +201,7 @@ struct SceneResources {
std::array<float, 4> previous_viewport{};
std::string previous_view_id;
};
constexpr std::uint32_t shadow_size = 1024;
constexpr std::uint32_t timestamp_capacity = 24;
} // namespace
struct Renderer::Impl {
RendererConfig config;
@@ -194,8 +231,10 @@ struct Renderer::Impl {
VkExtent2D swap_extent{};
std::vector<VkImage> swap_images;
std::vector<VkImageLayout> swap_layouts;
Image color, depth, shadow;
Image color, depth, shadow, local_shadow;
std::uint32_t sun_shadow_size{}, local_shadow_size{};
Buffer vertices, readback;
Buffer lighting_header, lighting_locals, lighting_views;
SceneResources scene;
InstanceTracker instance_tracker;
std::unordered_map<std::string, std::size_t> previous_lods;
@@ -212,6 +251,9 @@ struct Renderer::Impl {
std::unordered_map<const Texture*, CachedOpacity> 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,9 +358,13 @@ 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);
destroy(local_shadow);
if (device) {
destroy_scene_interfaces();
if (pipeline)
@@ -333,8 +379,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)
@@ -721,14 +771,39 @@ struct Renderer::Impl {
VkQueryPoolCreateInfo query{};
query.sType = VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO;
query.queryType = VK_QUERY_TYPE_TIMESTAMP;
query.queryCount = 12;
query.queryCount = timestamp_capacity;
check(vkCreateQueryPool(device, &query, nullptr, &timestamp_pool),
"Create GPU timestamp queries");
}
shadow =
make_image(shadow_size, shadow_size, VK_FORMAT_D32_SFLOAT,
VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT,
VK_IMAGE_ASPECT_DEPTH_BIT);
const auto shadow_usage = VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT |
VK_IMAGE_USAGE_SAMPLED_BIT;
for (const auto size : {2048u, 1024u}) {
if (size > max_image_dimension)
continue;
try {
shadow = make_image(size, size, VK_FORMAT_D32_SFLOAT, shadow_usage,
VK_IMAGE_ASPECT_DEPTH_BIT);
sun_shadow_size = size;
break;
} catch (const std::exception&) {
// Optional atlas allocation may fail; try the bounded half-size profile.
}
}
if (!shadow.handle)
shadow = make_image(1, 1, VK_FORMAT_D32_SFLOAT, shadow_usage,
VK_IMAGE_ASPECT_DEPTH_BIT);
for (const auto size : {2048u, 1024u}) {
if (size > max_image_dimension)
continue;
try {
local_shadow = make_image(size, size, VK_FORMAT_D32_SFLOAT,
shadow_usage, VK_IMAGE_ASPECT_DEPTH_BIT);
local_shadow_size = size;
break;
} catch (const std::exception&) {
// Local shadows are optional; all affected lights remain unshadowed.
}
}
make_targets();
make_descriptors();
make_pipelines();
@@ -889,6 +964,31 @@ struct Renderer::Impl {
pi.pPoolSizes = sizes;
check(vkCreateDescriptorPool(device, &pi, nullptr, &descriptor_pool),
"Create descriptor pool");
std::array<VkDescriptorSetLayoutBinding, 4> 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<std::uint32_t>(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 +1109,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<VkDescriptorSetLayout, 2> set_layouts{descriptor_layout, lighting_layout};
li.setLayoutCount = static_cast<std::uint32_t>(set_layouts.size());
li.pSetLayouts = set_layouts.data();
li.pushConstantRangeCount = 1;
li.pPushConstantRanges = &push;
check(vkCreatePipelineLayout(device, &li, nullptr, &pipeline_layout),
@@ -1220,14 +1321,14 @@ struct Renderer::Impl {
layout.pBindings = hzb.data();
check(vkCreateDescriptorSetLayout(device, &layout, nullptr, &scene.hzb_layout),
"Create HZB descriptor layout");
const std::array<VkDescriptorSetLayout, 2> scene_layouts{descriptor_layout,
scene.graphics_layout};
const std::array<VkDescriptorSetLayout, 3> 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<std::uint32_t>(scene_layouts.size());
pipeline_info.pSetLayouts = scene_layouts.data();
pipeline_info.pushConstantRangeCount = 1;
pipeline_info.pPushConstantRanges = &graphics_push;
@@ -1533,6 +1634,30 @@ struct Renderer::Impl {
VkBufferUsageFlags usage = 0) {
upload_scene_buffer(buffer, values.data(), values.size() * sizeof(T), usage);
}
void update_lighting_descriptors() {
const std::array<VkDescriptorBufferInfo, 3> 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,
local_shadow.handle ? local_shadow.view : shadow.view,
VK_IMAGE_LAYOUT_DEPTH_READ_ONLY_OPTIMAL};
std::array<VkWriteDescriptorSet, 4> 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<std::uint32_t>(writes.size()),
writes.data(), 0, nullptr);
}
void update_scene_descriptors(bool occlusion) {
auto write_buffers = [&](VkDescriptorSet set, std::span<const Buffer* const> buffers,
std::uint32_t first_binding) {
@@ -1625,6 +1750,22 @@ struct Renderer::Impl {
void render(const Snapshot& snapshot) {
auto start = std::chrono::steady_clock::now();
statistics.draw_calls = statistics.culled_meshes = statistics.gpu_label_count = 0;
statistics.submitted_local_lights = statistics.omitted_local_lights = 0;
statistics.requested_sun_cascades = statistics.effective_sun_cascades =
statistics.sun_shadow_caster_draws = 0;
statistics.sun_shadow_atlas_bytes = sun_shadow_size ? shadow.allocation_size : 0;
statistics.gpu_sun_shadow_ms = 0;
statistics.requested_local_shadow_faces = statistics.local_shadow_faces =
statistics.local_shadow_tiles = statistics.dropped_shadow_faces =
statistics.dropped_point_shadow_faces =
statistics.shadow_atlas_full_drops =
statistics.shadow_caster_budget_drops =
statistics.shadow_unavailable_drops =
statistics.shadow_caster_draws = 0;
statistics.local_shadow_atlas_bytes = local_shadow_size
? local_shadow.allocation_size : 0;
statistics.gpu_local_shadow_ms = 0;
statistics.effective_lighting_path = "forward";
statistics.gpu_bins = statistics.gpu_visible_instances =
statistics.gpu_frustum_rejected = statistics.gpu_occlusion_deferred =
statistics.gpu_post_visible = 0;
@@ -1715,6 +1856,8 @@ struct Renderer::Impl {
};
std::vector<SelectedDraw> selected_draws;
selected_draws.reserve(snapshot.draws.size());
std::vector<ShadowCasterBounds> shadow_casters;
shadow_casters.reserve(snapshot.draws.size());
struct BuildingBin {
const Mesh* mesh{};
const Texture* texture{};
@@ -1724,10 +1867,17 @@ struct Renderer::Impl {
std::vector<BuildingBin> building_bins;
std::unordered_map<const Mesh*, std::pair<std::uint32_t, std::uint32_t>> mesh_ranges;
std::unordered_map<std::string, std::size_t> current_lods;
for (const auto& item : snapshot.draws) {
for (std::size_t source_index = 0; source_index < snapshot.draws.size(); ++source_index) {
const auto& item = snapshot.draws[source_index];
if (!item.mesh || item.mesh->vertices.empty())
continue;
const auto source_bounds = world_bounds(item.mesh, item.model);
if (item.cast_shadow) {
if (source_index > UINT32_MAX)
throw std::overflow_error("Shadow source draw index exceeds 32-bit capacity");
shadow_casters.push_back(
{source_bounds, static_cast<std::uint32_t>(source_index)});
}
std::vector<float> thresholds;
std::vector<std::uint8_t> available;
std::shared_ptr<const Mesh> selected_mesh = item.mesh;
@@ -1858,12 +2008,63 @@ struct Renderer::Impl {
gpu_frame.textures.push_back(bin.texture);
}
gpu_frame.candidate_count = static_cast<std::uint32_t>(gpu_frame.candidates.size());
ShadowBudget shadow_budget;
shadow_budget.sun_atlas_size = sun_shadow_size;
shadow_budget.sun_atlas_available = sun_shadow_size != 0;
shadow_budget.local_atlas_size = local_shadow_size;
shadow_budget.local_atlas_available = local_shadow_size != 0;
const auto shadow_plan = build_shadow_plan(snapshot, shadow_casters, shadow_budget);
const bool sun_raster = sun_shadow_size &&
std::any_of(shadow_plan.sun_views.begin(), shadow_plan.sun_views.end(),
[](const ShadowView& view) {
return view.valid && !view.caster_indices.empty();
});
statistics.requested_sun_cascades = shadow_plan.requested_sun_cascades;
statistics.effective_sun_cascades = sun_raster
? shadow_plan.effective_sun_cascades : 0;
if (sun_raster)
for (const auto& view : shadow_plan.sun_views)
if (view.valid)
statistics.sun_shadow_caster_draws +=
static_cast<std::uint32_t>(view.caster_indices.size());
const bool local_raster = local_shadow_size &&
std::any_of(shadow_plan.local_views.begin(), shadow_plan.local_views.end(),
[](const ShadowView& view) {
return view.valid && !view.caster_indices.empty();
});
statistics.requested_local_shadow_faces = shadow_plan.local_faces_requested;
statistics.local_shadow_tiles = shadow_plan.local_faces_used;
statistics.local_shadow_faces = local_raster ? shadow_plan.local_faces_used : 0;
statistics.dropped_shadow_faces = shadow_plan.dropped_local_faces;
statistics.dropped_point_shadow_faces = shadow_plan.dropped_point_faces;
for (const auto& assignment : shadow_plan.local_assignments) {
if (assignment.valid || assignment.reason == ShadowDropReason::None)
continue;
const auto& light = snapshot.local_lights[assignment.source_index];
const auto faces = light.kind == LocalLight::Kind::Point ? 6u : 1u;
if (assignment.reason == ShadowDropReason::TileBudget)
statistics.shadow_atlas_full_drops += faces;
else if (assignment.reason == ShadowDropReason::CasterBudget)
statistics.shadow_caster_budget_drops += faces;
else if (assignment.reason == ShadowDropReason::Unavailable)
statistics.shadow_unavailable_drops += faces;
}
for (const auto& view : shadow_plan.sun_views) {
if (view.reason == ShadowDropReason::CasterBudget)
++statistics.shadow_caster_budget_drops;
else if (view.reason == ShadowDropReason::Unavailable)
++statistics.shadow_unavailable_drops;
}
statistics.shadow_caster_draws = statistics.sun_shadow_caster_draws;
if (local_raster)
for (const auto& view : shadow_plan.local_views)
statistics.shadow_caster_draws +=
static_cast<std::uint32_t>(view.caster_indices.size());
std::vector<GpuVertex> data;
std::vector<Batch> scene_batches, transparent_batches, shadow_batches,
sprite_batches, ui_batches;
std::vector<Batch> scene_batches, transparent_batches, sprite_batches, ui_batches;
for (const auto& selected : selected_draws) {
const auto& item = *selected.source;
if (selected.gpu && !item.cast_shadow)
if (selected.gpu)
continue;
auto first = data.size();
const auto& mesh = *selected.mesh;
@@ -1886,16 +2087,12 @@ struct Renderer::Impl {
continue;
Batch batch{static_cast<std::uint32_t>(first), count,
item.texture ? item.texture.get() : white.get()};
if (item.cast_shadow)
shadow_batches.push_back(batch);
if (!selected.gpu) {
if (outside(data, first))
++statistics.culled_meshes;
else if (gpu_active && !selected.opaque)
transparent_batches.push_back(batch);
else
scene_batches.push_back(batch);
}
if (outside(data, first))
++statistics.culled_meshes;
else if (gpu_active && !selected.opaque)
transparent_batches.push_back(batch);
else
scene_batches.push_back(batch);
}
struct OrderedSprite {
const Sprite* sprite;
@@ -1972,6 +2169,45 @@ struct Renderer::Impl {
triangles.texture ? triangles.texture.get() : white.get(),
triangles.clip_rect});
}
std::vector<Batch> shadow_batch_by_source(snapshot.draws.size());
if (sun_raster || local_raster) {
std::vector<std::uint8_t> required(snapshot.draws.size());
for (const auto& view : shadow_plan.sun_views)
if (sun_raster && view.valid)
for (const auto source : view.caster_indices)
required.at(source) = 1;
for (const auto& view : shadow_plan.local_views)
if (local_raster && view.valid)
for (const auto source : view.caster_indices)
required.at(source) = 1;
for (std::size_t source = 0; source < required.size(); ++source) {
if (!required[source])
continue;
const auto& item = snapshot.draws[source];
if (!item.mesh)
continue;
const auto first = data.size();
const auto& mesh = *item.mesh; // Source LOD 0, independent of camera/P2 LOD.
auto emit = [&](std::uint32_t index) {
if (index >= mesh.vertices.size())
throw std::out_of_range("Shadow mesh index outside vertex range");
data.push_back(gpu_vertex(mesh.vertices[index], item,
snapshot.view_projection));
};
if (mesh.indices.empty())
for (std::uint32_t i = 0; i < mesh.vertices.size(); ++i)
emit(i);
else
for (const auto index : mesh.indices)
emit(index);
const auto count = data.size() - first;
if (count % 3 || first > UINT32_MAX || count > UINT32_MAX)
throw std::invalid_argument("Shadow mesh must fit complete triangles");
shadow_batch_by_source[source] =
{static_cast<std::uint32_t>(first), static_cast<std::uint32_t>(count),
white.get()};
}
}
statistics.vertices = static_cast<std::uint32_t>(data.size() + gpu_frame.vertices.size());
auto byte_count = std::max<std::size_t>(sizeof(GpuVertex), data.size() * sizeof(GpuVertex));
if (vertices.size < byte_count) {
@@ -2017,19 +2253,114 @@ struct Renderer::Impl {
VK_BUFFER_USAGE_TRANSFER_DST_BIT);
update_scene_descriptors(occlusion);
}
Vec3 direction = snapshot.light_direction;
std::optional<SunLight> 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_raster ? 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};
}
lighting.counts[3] = static_cast<std::uint32_t>(shadow_plan.sun_views.size());
std::vector<ShadowViewGpu> gpu_shadow_views;
gpu_shadow_views.reserve(std::max<std::size_t>(1, shadow_plan.sun_views.size()));
for (std::size_t i = 0; i < shadow_plan.sun_views.size(); ++i) {
const auto& view = shadow_plan.sun_views[i];
ShadowViewGpu gpu{};
gpu.view_projection = view.view_projection;
gpu.tile_scale_offset = view.atlas_scale_offset;
gpu.guarded_clamp = view.guarded_clamp;
gpu.bias_flags = {.0008f, .003f,
sun_shadow_size ? 1.f / float(sun_shadow_size) : 0.f,
sun_raster && view.valid ? 1.f : 0.f};
gpu_shadow_views.push_back(gpu);
if (i < lighting.cascade_splits.size())
lighting.cascade_splits[i] = view.split_far;
}
for (const auto& view : shadow_plan.local_views) {
ShadowViewGpu gpu{};
gpu.view_projection = view.view_projection;
gpu.tile_scale_offset = view.atlas_scale_offset;
gpu.guarded_clamp = view.guarded_clamp;
gpu.bias_flags = {.0008f, .003f,
local_shadow_size ? 1.f / float(local_shadow_size) : 0.f,
local_raster && view.valid ? 1.f : 0.f};
gpu_shadow_views.push_back(gpu);
}
std::vector<const LocalShadowAssignment*> assignments(snapshot.local_lights.size());
for (const auto& assignment : shadow_plan.local_assignments)
assignments.at(assignment.source_index) = &assignment;
statistics.omitted_local_lights = shadow_plan.omitted_local_lights;
std::vector<LocalLightGpu> gpu_lights;
gpu_lights.reserve(shadow_plan.submitted_local_indices.size());
for (const auto source : shadow_plan.submitted_local_indices) {
const auto& local = snapshot.local_lights[source];
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};
const auto* assignment = assignments.at(source);
if (local_raster && assignment && assignment->valid) {
gpu.cone_type_shadow_view[2] = float(
shadow_plan.sun_views.size() + assignment->first_view);
gpu.cone_type_shadow_view[3] = float(assignment->face_count);
}
gpu_lights.push_back(gpu);
}
lighting.counts[0] = static_cast<std::uint32_t>(gpu_lights.size());
statistics.submitted_local_lights = lighting.counts[0];
if (gpu_lights.empty())
gpu_lights.push_back({}); // Descriptors always point at a full initialized record.
if (gpu_shadow_views.empty())
gpu_shadow_views.push_back({}); // Always bind an initialized record.
upload_scene_buffer(lighting_header, &lighting, sizeof(lighting), 0);
upload_scene_vector(lighting_locals, gpu_lights);
upload_scene_vector(lighting_views, gpu_shadow_views);
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),
look_at(light_eye, {0, 0, 0}, light_up)),
Push push{sun_raster && !shadow_plan.sun_views.empty()
? shadow_plan.sun_views.front().view_projection
: multiply(orthographic(-20, 20, -20, 20, .1f, 80),
look_at(light_eye, {0, 0, 0}, light_up)),
{direction[0], direction[1], direction[2], 0},
{snapshot.eye[0], snapshot.eye[1], snapshot.eye[2], 1}};
std::optional<std::uint32_t> swap_index;
@@ -2053,7 +2384,7 @@ struct Renderer::Impl {
std::uint32_t timestamp_cursor = 0;
std::vector<std::string> timestamp_labels;
if (timestamp_pool) {
vkCmdResetQueryPool(command, timestamp_pool, 0, 12);
vkCmdResetQueryPool(command, timestamp_pool, 0, timestamp_capacity);
vkCmdWriteTimestamp2(command, VK_PIPELINE_STAGE_2_TOP_OF_PIPE_BIT,
timestamp_pool, timestamp_cursor++);
}
@@ -2078,6 +2409,12 @@ struct Renderer::Impl {
vkCmdSetViewport(command, 0, 1, &viewport);
vkCmdSetScissor(command, 0, 1, &scissor);
};
auto bind_material = [&](VkDescriptorSet material) {
const std::array<VkDescriptorSet, 2> sets{material, lighting_set};
vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline_layout,
0, static_cast<std::uint32_t>(sets.size()), sets.data(),
0, nullptr);
};
auto draw_transparent = [&] {
if (transparent_batches.empty())
return;
@@ -2088,8 +2425,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 +2438,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 +2460,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;
}
@@ -2181,7 +2515,7 @@ struct Renderer::Impl {
}
} end{*this};
callback();
if (timestamp_pool && timestamp_cursor < 12) {
if (timestamp_pool && timestamp_cursor < timestamp_capacity) {
vkCmdWriteTimestamp2(command,
VK_PIPELINE_STAGE_2_BOTTOM_OF_PIPE_BIT,
timestamp_pool, timestamp_cursor++);
@@ -2189,35 +2523,79 @@ struct Renderer::Impl {
}
});
};
add_pass("ShadowMap", {}, {"shadow"}, [&] {
transition(command, shadow, VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL,
VK_IMAGE_ASPECT_DEPTH_BIT);
VkRenderingAttachmentInfo attachment{};
attachment.sType = VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO;
attachment.imageView = shadow.view;
attachment.imageLayout = VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL;
attachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
attachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE;
attachment.clearValue.depthStencil = {1, 0};
VkRenderingInfo rendering{};
rendering.sType = VK_STRUCTURE_TYPE_RENDERING_INFO;
rendering.renderArea = {{0, 0}, {shadow_size, shadow_size}};
rendering.layerCount = 1;
rendering.pDepthAttachment = &attachment;
vkCmdBeginRendering(command, &rendering);
set_viewport(shadow_size, shadow_size);
vkCmdBindPipeline(command, VK_PIPELINE_BIND_POINT_GRAPHICS, shadow_pipeline);
vkCmdPushConstants(command, pipeline_layout,
VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 0,
sizeof(push), &push);
for (auto batch : shadow_batches) {
vkCmdDraw(command, batch.count, 1, batch.first, 0);
++statistics.draw_calls;
}
vkCmdEndRendering(command);
transition(command, shadow, VK_IMAGE_LAYOUT_DEPTH_READ_ONLY_OPTIMAL,
VK_IMAGE_ASPECT_DEPTH_BIT);
});
auto raster_shadow_atlas = [&](Image& atlas, std::uint32_t atlas_size,
const std::vector<ShadowView>& views) {
transition(command, atlas, VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL,
VK_IMAGE_ASPECT_DEPTH_BIT);
VkRenderingAttachmentInfo attachment{};
attachment.sType = VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO;
attachment.imageView = atlas.view;
attachment.imageLayout = VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL;
attachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
attachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE;
attachment.clearValue.depthStencil = {1, 0};
VkRenderingInfo rendering{};
rendering.sType = VK_STRUCTURE_TYPE_RENDERING_INFO;
rendering.renderArea = {{0, 0}, {atlas_size, atlas_size}};
rendering.layerCount = 1;
rendering.pDepthAttachment = &attachment;
vkCmdBeginRendering(command, &rendering);
vkCmdBindPipeline(command, VK_PIPELINE_BIND_POINT_GRAPHICS,
shadow_pipeline);
for (const auto& view : views) {
if (!view.valid || view.caster_indices.empty())
continue;
const auto guard = (view.tile_size - view.usable_size) / 2;
const auto x = view.tile_origin_x + guard;
const auto y = view.tile_origin_y + guard;
const VkViewport viewport{float(x), float(y), float(view.usable_size),
float(view.usable_size), 0, 1};
const VkRect2D scissor{{static_cast<std::int32_t>(x),
static_cast<std::int32_t>(y)},
{view.usable_size, view.usable_size}};
vkCmdSetViewport(command, 0, 1, &viewport);
vkCmdSetScissor(command, 0, 1, &scissor);
auto view_push = push;
view_push.light_view_projection = view.view_projection;
vkCmdPushConstants(command, pipeline_layout,
VK_SHADER_STAGE_VERTEX_BIT |
VK_SHADER_STAGE_FRAGMENT_BIT,
0, sizeof(view_push), &view_push);
for (const auto source : view.caster_indices) {
const auto& batch = shadow_batch_by_source.at(source);
if (!batch.count)
continue;
vkCmdDraw(command, batch.count, 1, batch.first, 0);
++statistics.draw_calls;
}
}
vkCmdEndRendering(command);
transition(command, atlas, VK_IMAGE_LAYOUT_DEPTH_READ_ONLY_OPTIMAL,
VK_IMAGE_ASPECT_DEPTH_BIT);
};
if (sun_raster)
add_pass("SunShadowAtlas", {}, {"shadow"}, [&] {
raster_shadow_atlas(shadow, sun_shadow_size, shadow_plan.sun_views);
});
else
add_pass("ShadowFallback", {}, {"shadow"}, [&] {
// A bound descriptor still needs a matching image layout, even when
// every graphics shader branch treats its shadow as unshadowed.
transition(command, shadow, VK_IMAGE_LAYOUT_DEPTH_READ_ONLY_OPTIMAL,
VK_IMAGE_ASPECT_DEPTH_BIT);
});
if (local_raster)
add_pass("LocalShadowAtlas", {}, {"local_shadow"}, [&] {
raster_shadow_atlas(local_shadow, local_shadow_size,
shadow_plan.local_views);
});
else
add_pass("LocalShadowFallback", {}, {"local_shadow"}, [&] {
if (local_shadow.handle)
transition(command, local_shadow,
VK_IMAGE_LAYOUT_DEPTH_READ_ONLY_OPTIMAL,
VK_IMAGE_ASPECT_DEPTH_BIT);
});
if (gpu_active)
add_pass("MainCull", {"shadow"},
{"main_indirect", "main_visible", "deferred_ids"}, [&] {
@@ -2277,8 +2655,9 @@ struct Renderer::Impl {
}
});
add_pass(occlusion ? "MainRaster" : "ForwardAndUI",
gpu_active ? std::vector<std::string>{"shadow", "main_indirect", "main_visible"}
: std::vector<std::string>{"shadow"},
gpu_active ? std::vector<std::string>{"shadow", "local_shadow",
"main_indirect", "main_visible"}
: std::vector<std::string>{"shadow", "local_shadow"},
{"color", "depth"}, [&] {
transition(command, color, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
VK_IMAGE_ASPECT_COLOR_BIT);
@@ -2313,8 +2692,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 +2700,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<VkDescriptorSet, 2> sets{descriptor,
const std::array<VkDescriptorSet, 3> sets{descriptor, lighting_set,
scene.graphics_main};
vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS,
scene.graphics_pipeline_layout, 0, sets.size(),
@@ -2346,8 +2724,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 +2823,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<VkDescriptorSet, 2> sets{descriptor,
const std::array<VkDescriptorSet, 3> sets{descriptor, lighting_set,
scene.graphics_post};
vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS,
scene.graphics_pipeline_layout, 0,
@@ -2526,7 +2903,7 @@ struct Renderer::Impl {
graph.execute();
submit(swap_index.has_value());
if (timestamp_pool) {
std::array<std::uint64_t, 12> stamps{};
std::array<std::uint64_t, timestamp_capacity> stamps{};
check(vkGetQueryPoolResults(device, timestamp_pool, 0, timestamp_cursor,
timestamp_cursor * sizeof(std::uint64_t), stamps.data(),
sizeof(std::uint64_t),
@@ -2546,6 +2923,8 @@ struct Renderer::Impl {
const auto elapsed = milliseconds(stamps[i], stamps[i + 1]);
const auto& label = timestamp_labels[i];
if (label == "MainCull") statistics.gpu_main_cull_ms = elapsed;
else if (label == "SunShadowAtlas") statistics.gpu_sun_shadow_ms = elapsed;
else if (label == "LocalShadowAtlas") statistics.gpu_local_shadow_ms = elapsed;
else if (label == "MainRaster" || label == "ForwardAndUI")
statistics.gpu_main_raster_ms = elapsed;
else if (label == "BuildCurrentHZB") statistics.gpu_hzb_ms = elapsed;
@@ -2618,7 +2997,11 @@ 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 +
local_shadow.allocation_size +
lighting_header.allocation_size +
lighting_locals.allocation_size +
lighting_views.allocation_size;
statistics.texture_count = static_cast<std::uint32_t>(textures.size());
for (const auto& [_, texture] : textures)
statistics.gpu_allocated_bytes += texture.image.allocation_size;
+12 -4
View File
@@ -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<bool>(),
"vertex texture bindings are unsupported");
}
@@ -98,7 +106,7 @@ void validate_gpu_layout(const Json& layout, std::string_view entry) {
const std::array<int, 3> 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];