Checkpoint 5: complete asset freshness, schema migrations and editor diagnostics

This commit is contained in:
Emil
2026-09-18 05:43:02 +03:00
parent e0b965166e
commit 0f34b03631
37 changed files with 1438 additions and 52 deletions
+70
View File
@@ -844,6 +844,76 @@ ImportResult AssetPipeline::import_asset(const ImportRequest& request, ImportJob
}
return result;
}
Json AssetPipeline::freshness(const std::string& id) const {
Json result{{"state", "current"}, {"reasons", Json::array()}};
auto stale = [&](const std::string& code, const fs::path& path, const std::string& message) {
result["state"] = "stale";
result["reasons"].push_back(
{{"code", code}, {"path", faset::path_to_utf8(path)}, {"message", message}});
};
try {
const auto manifest = current_manifest(id);
result["generation"] = manifest.at("generation");
const auto source = faset::path_from_utf8(manifest.at("source").get<std::string>());
const auto payload =
faset::path_from_utf8(manifest.at("payload_source").get<std::string>());
const auto& key = manifest.at("input_key");
auto check_file = [&](const fs::path& path, const std::string& digest,
const std::string& code) {
try {
if (faset::sha256_file(path) != digest)
stale(code, path, "Input changed; reimport to update the active generation");
} catch (const std::exception& error) {
stale("input.unavailable", path, error.what());
}
};
check_file(payload, manifest.at("source_sha256"), "source.changed");
if (key.contains("bundle_sha256")) {
check_file(source, key.at("bundle_sha256"), "bundle.changed");
// The bundle can declare more than its selected GLB. Validate every
// declared payload, as import does, even when the manifest is unchanged.
try {
const auto bundle = read_json(source);
for (const auto& file : bundle.at("files")) {
const auto relative = faset::path_from_utf8(file.at("path").get<std::string>());
if (relative.is_absolute() ||
faset::generic_path_to_utf8(relative).find("..") != std::string::npos)
throw std::runtime_error("Invalid bundle payload path");
check_file(source.parent_path() / relative, file.at("sha256"),
"bundle.payload_changed");
}
} catch (const std::exception& error) {
stale("bundle.unavailable", source, error.what());
}
}
for (const auto& [uri, digest] : key.at("dependencies").items())
check_file(external_path(payload, uri), digest, "dependency.changed");
const auto sidecar =
faset::path_from_utf8(faset::path_to_utf8(source) + ".faset-import.json");
try {
const auto metadata = read_json(sidecar);
if (metadata.value("schema_version", 0) != 1 || metadata.value("asset_id", "") != id ||
metadata.value("settings", Json::object()) != manifest.at("settings"))
stale("settings.changed", sidecar,
"Import identity or recipe changed; reimport required");
} catch (const std::exception& error) {
stale("settings.unavailable", sidecar, error.what());
}
const bool image = manifest.value("kind", "scene") == "image";
const std::string recipe = image ? "faset-image-1/stb-2.30" : importer_version;
const std::string profile = image ? "desktop-image-v1" : "desktop-static-pbr-v1";
const Json toolchain{{"cgltf", FASET_CGLTF_COMMIT}, {"stb", FASET_STB_COMMIT}};
if (key.at("importer") != recipe || key.at("target_profile") != profile ||
key.at("toolchain") != toolchain)
stale("importer.changed", source,
"Importer, toolchain or target profile changed; reimport required");
} catch (const std::exception& error) {
result["state"] = "unavailable";
result["reasons"].push_back({{"code", "manifest.unavailable"}, {"message", error.what()}});
}
return result;
}
Json AssetPipeline::overrides(const std::string& id) const {
const auto source = current_manifest(id).at("source").get<std::string>();
const auto path = faset::path_from_utf8(source + ".faset-overrides.json");
+44 -2
View File
@@ -100,6 +100,25 @@ SchemaRegistry gameplay_schemas(const Json& manifest) {
"Gameplay schema duplicates a builtin or gameplay TypeId: " + id);
result.register_schema(schema);
}
for (const auto& schema : types) {
if (!schema.contains("migrations"))
continue;
require(schema.at("migrations").is_array(), "migration.invalid",
"Migrations must be an array of version steps");
for (const auto& step : schema.at("migrations")) {
require(
step.is_object() && step.contains("from_version") &&
step.at("from_version").is_number_integer() && step.at("from_version") > 0 &&
step.at("from_version") < schema.value("version", 1) &&
step.contains("fields") && step.at("fields").is_object(),
"migration.invalid", "Migration requires a supported earlier version and fields");
for (const auto& [key, unused] : step.items())
require(key == "from_version" || key == "fields", "migration.invalid",
"Unsupported migration step property: " + key);
result.add_migration(schema.at("id"), step.at("from_version").get<int>(),
step.at("fields"));
}
}
return result;
}
bool SchemaRegistry::contains(const std::string& type) const {
@@ -139,9 +158,25 @@ void SchemaRegistry::validate_component(const Json& component) const {
validate_field(value, metadata["fields"][id]);
}
void SchemaRegistry::add_migration(const std::string& type, int from_version, Json rules) {
require(from_version > 0 && rules.is_object(), "migration.invalid", "Invalid migration");
require(contains(type) && from_version > 0 && from_version < schema(type).value("version", 1) &&
rules.is_object(),
"migration.invalid", "Migration requires a registered type and an earlier version");
require(!migrations_.contains({type, from_version}), "migration.duplicate",
"Migration already exists");
for (const auto& [field, rule] : rules.items()) {
require(!field.empty() && rule.is_object(), "migration.invalid",
"Migration fields require nonempty IDs and rule objects");
for (const auto& [operation, value] : rule.items()) {
require(operation == "default" || operation == "scale" || operation == "require_manual",
"migration.invalid", "Unsupported migration operation: " + operation);
if (operation == "scale")
require(value.is_number() && std::isfinite(value.get<double>()),
"migration.invalid", "Migration scale must be a finite number");
else if (operation == "require_manual")
require(value.is_boolean(), "migration.invalid",
"Migration require_manual must be a boolean");
}
}
migrations_[{type, from_version}] = std::move(rules);
}
Json SchemaRegistry::migrate_component(const Json& source) const {
@@ -150,6 +185,10 @@ Json SchemaRegistry::migrate_component(const Json& source) const {
if (!contains(type))
return result;
const auto current = schema(type).value("version", 1);
if (result.contains("version"))
require(result.at("version").is_number_integer() && result.at("version") > 0 &&
result.at("version") <= std::numeric_limits<int>::max(),
"migration.invalid", "Component version must be a positive supported integer");
auto version = result.value("version", 1);
if (version > current)
return result;
@@ -163,8 +202,11 @@ Json SchemaRegistry::migrate_component(const Json& source) const {
if (rule.contains("scale") && result["fields"].contains(field)) {
require(result["fields"][field].is_number(), "migration.type",
"Cannot scale a nonnumeric field");
result["fields"][field] =
const auto scaled =
result["fields"][field].get<double>() * rule["scale"].get<double>();
require(std::isfinite(scaled), "migration.nonfinite",
"Migration produced a non-finite field value");
result["fields"][field] = scaled;
}
if (rule.value("require_manual", false) && result["fields"].contains(field))
throw Error("migration.manual", "Field requires explicit manual migration",
+18 -6
View File
@@ -288,9 +288,6 @@ Json AuthoringService::open(const std::filesystem::path& relative, bool recover)
value.data = recovered.at("scene");
value.revision = recovered.value("revision", 0u);
}
for (auto& item : value.data["entities"])
for (auto& component : item["components"])
component = schemas_.migrate_component(component);
documents_.emplace(id, std::move(value));
return summary(state(id));
}
@@ -375,6 +372,24 @@ void AuthoringService::apply(Json& scene, const Json& command) {
[&](const Json& value) { return value.at("id") == id; });
require(found != values.end(), "component.missing", "Component does not exist");
values.erase(found);
} else if (op == "component.migrate") {
Json* object = nullptr;
if (command.contains("instance")) {
const auto instance_id = command.at("instance").get<std::string>();
for (auto& instance : scene.at("instances"))
if (instance.at("id") == instance_id && instance.contains("additions"))
for (auto& addition : instance.at("additions"))
if (addition.at("id") == command.at("entity"))
object = &addition;
require(object, "template.addition_missing", "Instance-local entity is unavailable");
} else
object = &entity(scene, command.at("entity").get<std::string>());
auto& value = component(*object, command.at("component").get<std::string>());
const auto type = value.at("type").get<std::string>();
require(schemas_.contains(type), "schema.missing", "Component schema unavailable: " + type);
require(value.value("version", 1) <= schemas_.schema(type).value("version", 1),
"migration.future", "Cannot migrate a component from a newer schema");
value = schemas_.migrate_component(value);
} else if (op == "component.set") {
auto& value = component(entity(scene, command.at("entity").get<std::string>()),
command.at("component").get<std::string>());
@@ -601,9 +616,6 @@ Json AuthoringService::recover(const std::string& id,
"recovery.disk_conflict",
"Scene file changed or disappeared since recovery was written");
}
for (auto& item : candidate.data["entities"])
for (auto& component : item["components"])
component = schemas_.migrate_component(component);
validate_scene(candidate.data, schemas_);
if (documents_.contains(id)) {
const auto& current = state(id);
+1 -1
View File
@@ -17,7 +17,7 @@ struct Resolver {
const SceneLoader& loader;
std::string root;
Json conflicts = Json::array();
std::set<std::string> sources;
std::set<std::string> sources{};
void conflict(const Json& path, std::string code, const Json& record) {
conflicts.push_back(
{{"instance_path", path}, {"code", std::move(code)}, {"record", record}});
+10 -2
View File
@@ -4,6 +4,7 @@
#include <condition_variable>
#include <deque>
#include <faset/assets/asset_data.hpp>
#include <faset/assets/asset_pipeline.hpp>
#include <faset/authoring/schema.hpp>
#include <faset/core/hash.hpp>
#include <faset/core/io.hpp>
@@ -225,9 +226,16 @@ struct BuildService::Impl {
}
}
void validate_assets(const Json& scene) {
assets::AssetStore pipeline(config.cache_root);
for (const auto& id : asset_references(scene))
assets::AssetPipeline pipeline(config.cache_root);
for (const auto& id : asset_references(scene)) {
pipeline.load_asset(id);
const auto freshness = pipeline.freshness(id);
if (freshness.value("state", std::string("unavailable")) != "current")
throw std::runtime_error("Asset " + id +
" is stale or unavailable. Reimport before cooking or "
"exporting. Details: " +
freshness.dump());
}
}
Json build(Job& job, bool exporting = false) {
const auto& configuration = exporting ? config.export_configuration : config.configuration;
+2 -1
View File
@@ -114,7 +114,8 @@ Commands::Commands(authoring::AuthoringService& authoring) : authoring_(authorin
true);
add("faset_scene_edit",
"Apply one atomic authoring batch with optimistic revision checking and one Undo step. "
"Operations: entity.create/rename/delete/duplicate/reparent; component.add/remove/set; "
"Operations: entity.create/rename/delete/duplicate/reparent; "
"component.add/remove/set/migrate; "
"scene.rename/simulation; "
"template.instance/override/revert/suppress/restore/add/addition_set/reparent/remove/"
"source_set. Use "
+265
View File
@@ -0,0 +1,265 @@
#include <algorithm>
#include <cmath>
#include <faset/editor/debug_overlay.hpp>
#include <imgui.h>
#include <stdexcept>
#include <string_view>
namespace faset::editor {
namespace {
struct CurrentContext {
ImGuiContext* previous{ImGui::GetCurrentContext()};
explicit CurrentContext(ImGuiContext* context) {
ImGui::SetCurrentContext(context);
}
~CurrentContext() {
ImGui::SetCurrentContext(previous);
}
};
ImGuiKey key(std::string_view name) {
if (name.size() == 1 && name[0] >= 'A' && name[0] <= 'Z')
return static_cast<ImGuiKey>(ImGuiKey_A + name[0] - 'A');
if (name.size() == 1 && name[0] >= '0' && name[0] <= '9')
return static_cast<ImGuiKey>(ImGuiKey_0 + name[0] - '0');
const std::pair<std::string_view, ImGuiKey> names[] = {
{"Tab", ImGuiKey_Tab}, {"Left", ImGuiKey_LeftArrow},
{"Right", ImGuiKey_RightArrow}, {"Up", ImGuiKey_UpArrow},
{"Down", ImGuiKey_DownArrow}, {"PageUp", ImGuiKey_PageUp},
{"PageDown", ImGuiKey_PageDown}, {"Home", ImGuiKey_Home},
{"End", ImGuiKey_End}, {"Insert", ImGuiKey_Insert},
{"Delete", ImGuiKey_Delete}, {"Backspace", ImGuiKey_Backspace},
{"Space", ImGuiKey_Space}, {"Return", ImGuiKey_Enter},
{"Escape", ImGuiKey_Escape}};
for (const auto& [label, value] : names)
if (label == name)
return value;
return ImGuiKey_None;
}
} // namespace
struct DebugOverlay::Impl {
ImGuiContext* context{};
bool visible{}, freeze{};
std::uint32_t overlay_buttons{}, editor_buttons{};
std::array<float, 2> pointer{-1, -1};
float scale{};
std::array<float, 4> window_rect{};
render::FrameStats displayed;
std::shared_ptr<render::Texture> atlas;
Impl() {
IMGUI_CHECKVERSION();
auto* previous = ImGui::GetCurrentContext();
context = ImGui::CreateContext();
auto& io = ImGui::GetIO();
io.IniFilename = nullptr;
io.LogFilename = nullptr;
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
io.BackendFlags |= ImGuiBackendFlags_RendererHasVtxOffset;
io.BackendPlatformName = "faset_events";
io.BackendRendererName = "faset_ui_triangles";
unsigned char* pixels{};
int width{}, height{};
io.Fonts->GetTexDataAsRGBA32(&pixels, &width, &height);
atlas = std::make_shared<render::Texture>();
atlas->width = width;
atlas->height = height;
atlas->rgba.assign(pixels, pixels + std::size_t(width) * height * 4);
io.Fonts->SetTexID(ImTextureID{1});
ImGui::SetCurrentContext(previous);
}
~Impl() {
ImGui::DestroyContext(context);
}
};
DebugOverlay::DebugOverlay() : impl_(std::make_unique<Impl>()) {}
DebugOverlay::~DebugOverlay() = default;
bool DebugOverlay::visible() const {
return impl_->visible;
}
void DebugOverlay::set_visible(bool value) {
impl_->visible = value;
}
std::vector<render::Event> DebugOverlay::process_events(std::span<const render::Event> events) {
CurrentContext current(impl_->context);
auto& io = ImGui::GetIO();
std::vector<render::Event> forwarded;
const auto pointer_in_overlay = [&] {
return impl_->visible && impl_->pointer[0] >= impl_->window_rect[0] &&
impl_->pointer[1] >= impl_->window_rect[1] &&
impl_->pointer[0] < impl_->window_rect[0] + impl_->window_rect[2] &&
impl_->pointer[1] < impl_->window_rect[1] + impl_->window_rect[3];
};
const auto capture_pointer = [&] {
if (impl_->overlay_buttons)
return true;
if (impl_->editor_buttons)
return false;
return pointer_in_overlay();
};
for (const auto& event : events) {
using Type = render::Event::Type;
if ((event.type == Type::KeyDown || event.type == Type::KeyUp) && event.key == "F12") {
if (event.type == Type::KeyDown && !event.repeat)
impl_->visible = !impl_->visible;
continue;
}
io.AddKeyEvent(ImGuiMod_Ctrl, event.control);
io.AddKeyEvent(ImGuiMod_Shift, event.shift);
io.AddKeyEvent(ImGuiMod_Alt, event.alt);
bool captured{};
bool pointer_event{};
switch (event.type) {
case Type::MouseMove:
io.AddMousePosEvent(event.x, event.y);
impl_->pointer = {event.x, event.y};
pointer_event = true;
captured = capture_pointer();
break;
case Type::MouseDown:
case Type::MouseUp:
io.AddMousePosEvent(event.x, event.y);
impl_->pointer = {event.x, event.y};
pointer_event = true;
captured = capture_pointer();
if (event.button >= 1 && event.button <= 5) {
const int buttons[] = {0, 2, 1, 3, 4};
io.AddMouseButtonEvent(buttons[event.button - 1], event.type == Type::MouseDown);
const auto mask = std::uint32_t{1} << (event.button - 1);
if (event.type == Type::MouseDown) {
if (captured)
impl_->overlay_buttons |= mask;
else
impl_->editor_buttons |= mask;
} else {
if (impl_->overlay_buttons & mask)
captured = true;
else if (impl_->editor_buttons & mask)
captured = false;
impl_->overlay_buttons &= ~mask;
impl_->editor_buttons &= ~mask;
}
}
break;
case Type::Wheel:
io.AddMouseWheelEvent(event.x, event.y);
pointer_event = true;
captured = capture_pointer();
break;
case Type::KeyDown:
case Type::KeyUp:
if (const auto mapped = key(event.key); mapped != ImGuiKey_None)
io.AddKeyEvent(mapped, event.type == Type::KeyDown);
captured = io.WantCaptureKeyboard;
break;
case Type::TextInput:
io.AddInputCharactersUTF8(event.text.c_str());
captured = io.WantCaptureKeyboard;
break;
case Type::FocusGained:
case Type::FocusLost:
io.AddFocusEvent(event.type == Type::FocusGained);
if (event.type == Type::FocusLost)
impl_->overlay_buttons = impl_->editor_buttons = 0;
break;
default:
break;
}
if (!captured || (!pointer_event && !impl_->visible))
forwarded.push_back(event);
}
return forwarded;
}
void DebugOverlay::append(render::Snapshot& output, const render::Renderer& renderer, float delta) {
CurrentContext current(impl_->context);
auto& state = *impl_;
auto& io = ImGui::GetIO();
io.DisplaySize = {float(renderer.width()), float(renderer.height())};
io.DisplayFramebufferScale = {1, 1}; // Faset events and geometry already use drawable pixels.
io.DeltaTime = std::isfinite(delta) && delta > 0 ? std::clamp(delta, .0001f, .1f) : 1.f / 60.f;
const float scale = renderer.display_scale();
if (state.scale != scale) {
auto& style = ImGui::GetStyle();
style = ImGuiStyle{};
ImGui::StyleColorsDark();
style.ScaleAllSizes(scale);
style.FontScaleMain = scale;
if (state.window_rect[2] == 0)
state.window_rect = {16 * scale, 44 * scale, 390 * scale, 310 * scale};
state.scale = scale;
}
// Complete an ImGui frame while hidden too, so queued input cannot accumulate.
ImGui::NewFrame();
if (state.visible) {
ImGui::SetNextWindowPos({16 * scale, 44 * scale}, ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize({390 * scale, 310 * scale}, ImGuiCond_FirstUseEver);
if (ImGui::Begin("Faset diagnostics (F12)", &state.visible,
ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoCollapse)) {
const auto position = ImGui::GetWindowPos(), size = ImGui::GetWindowSize();
state.window_rect = {position.x, position.y, size.x, size.y};
if (!state.freeze)
state.displayed = renderer.stats();
const auto& stats = state.displayed;
ImGui::TextUnformatted("Previous completed frame");
ImGui::TextWrapped("%s", stats.device.c_str());
ImGui::Checkbox("Freeze counters", &state.freeze);
ImGui::Separator();
ImGui::Text("Frame: %llu", static_cast<unsigned long long>(stats.frame));
ImGui::Text("Render call (wall): %.3f ms", stats.cpu_ms);
if (stats.gpu_ms > 0)
ImGui::Text("GPU: %.3f ms", stats.gpu_ms);
else
ImGui::TextUnformatted("GPU timestamps: unavailable");
ImGui::Text("Readback (wall): %.3f ms", stats.readback_cpu_ms);
ImGui::Text("Draws: %u Packed vertices: %u", stats.draw_calls, stats.vertices);
ImGui::Text("Culled meshes: %u Textures: %u", stats.culled_meshes,
stats.texture_count);
ImGui::Text("Vulkan allocations: %.2f MiB",
double(stats.gpu_allocated_bytes) / 1048576.0);
ImGui::Text("Validation: %s Errors: %u",
stats.validation_enabled ? "on" : "unavailable/off",
stats.validation_errors);
ImGui::Text("GPU pass labels: %s (%u)", stats.gpu_labels_enabled ? "on" : "unavailable",
stats.gpu_label_count);
}
ImGui::End();
}
ImGui::Render();
const auto* data = ImGui::GetDrawData();
if (!data || !data->Valid)
return;
for (const auto* list : data->CmdLists) {
for (const auto& command : list->CmdBuffer) {
if (command.UserCallback) {
if (command.UserCallback != ImDrawCallback_ResetRenderState)
command.UserCallback(list, &command);
continue;
}
if (command.GetTexID() != ImTextureID{1})
throw std::runtime_error("Unsupported texture in Faset diagnostic overlay");
const float x = command.ClipRect.x - data->DisplayPos.x;
const float y = command.ClipRect.y - data->DisplayPos.y;
const float width = command.ClipRect.z - command.ClipRect.x;
const float height = command.ClipRect.w - command.ClipRect.y;
if (width <= 0 || height <= 0)
continue;
render::UiTriangles batch;
batch.texture = state.atlas;
batch.clip_rect = {x, y, width, height};
batch.vertices.reserve(command.ElemCount);
for (unsigned i = 0; i < command.ElemCount; ++i) {
const auto index = list->IdxBuffer[command.IdxOffset + i] + command.VtxOffset;
const auto& vertex = list->VtxBuffer[index];
const auto channel = [&](unsigned shift) {
return float((vertex.col >> shift) & 0xffu) / 255.f;
};
batch.vertices.push_back(
{{vertex.pos.x - data->DisplayPos.x, vertex.pos.y - data->DisplayPos.y, 0},
{},
{channel(IM_COL32_R_SHIFT), channel(IM_COL32_G_SHIFT),
channel(IM_COL32_B_SHIFT), channel(IM_COL32_A_SHIFT)},
{vertex.uv.x, vertex.uv.y}});
}
output.ui_triangles.push_back(std::move(batch));
}
}
}
} // namespace faset::editor
+59 -4
View File
@@ -847,6 +847,24 @@ struct EditorUI::Impl {
transaction(Json::array(
{{{"op", "component.remove"}, {"entity", selected}, {"component", cid}}}));
}
void migrate_component(const std::string& cid) {
const auto* object = entity(resolved, selected);
if (!object)
return;
Json operation = {{"op", "component.migrate"}, {"entity", selected}, {"component", cid}};
if (owned_addition(*object)) {
operation["instance"] = object->at("origin").at("path").front();
operation["entity"] = object->at("origin").at("object");
for (const auto& c : object->at("components"))
if (c.at("id") == cid)
operation["component"] = c.at("source_id");
} else if (inherited(*object)) {
open_template_source(object->at("origin").at("path"),
object->at("origin").at("object"));
return;
}
transaction(Json::array({operation}));
}
Json relative_address(const Json& object, const std::string& component_id = {},
const std::string& field = {}) const {
const auto& origin = object.at("origin");
@@ -1119,7 +1137,9 @@ struct EditorUI::Impl {
return;
current = session.authoring().query(document);
schemas = session.authoring().schemas().manifest();
const auto document_stamp = session.authoring().documents().dump();
// Resolution also depends on schemas: a rebuilt component version may
// invalidate an override or change how entity references are remapped.
const auto document_stamp = Json::array({session.authoring().documents(), schemas}).dump();
if (shown_revision != current.at("revision").get<std::uint64_t>() ||
resolved_stamp != document_stamp) {
resolved_stamp = document_stamp;
@@ -1440,6 +1460,24 @@ struct EditorUI::Impl {
if (!known) {
label(body, "opaque-note-" + cid, "Schema unavailable. Data is preserved.");
keep.insert("opaque-note-" + cid);
if (has_schema &&
c.value("version", 1) <
session.authoring().schemas().schema(type).value("version", 1)) {
const auto version =
session.authoring().schemas().schema(type).value("version", 1);
label(body, "opaque-note-" + cid,
"Stored v" + std::to_string(c.value("version", 1)) + " / schema v" +
std::to_string(version) + ". Migration is explicit and undoable.");
const auto action_id = "component-migrate-" + cid;
auto& action = button(body, action_id,
source_object && !local_addition
? "Open source to migrate"
: "Migrate to v" + std::to_string(version),
[this, cid] { migrate_component(cid); });
action.tooltip =
"Apply the schema's declared migration rules. Errors keep all stored data.";
keep.insert(action_id);
}
auto& raw =
body.add(Kind::TextField, "opaque-fields-" + cid, c.at("fields").dump());
raw.enabled = false;
@@ -1617,13 +1655,30 @@ struct EditorUI::Impl {
asset.contains("manifest")
? path_to_utf8(path_from_utf8(asset["manifest"].value("source", id)).filename())
: id;
auto& row = list.add(Kind::TreeRow, "asset-" + id, "Imported / " + name);
const auto freshness = asset.value("freshness", Json::object());
const auto state = freshness.value("state", std::string("unavailable"));
const auto prefix = state == "current" ? "Imported"
: state == "stale" ? "Stale"
: "Unavailable";
auto& row =
list.add(Kind::TreeRow, "asset-" + id, std::string(prefix) + " / " + name);
row.layout.height = 25;
row.indent = 1;
row.drag_payload = {{"kind", "asset"}, {"id", id}, {"label", name}};
row.on_click = [this, id](Widget&) {
row.on_click = [this, id, asset, freshness, state](Widget&) {
renderer.set_clipboard(id);
status = "Asset ID copied; drag to viewport or an asset field";
if (asset.contains("manifest")) {
source_file = generic_path_to_utf8(std::filesystem::relative(
path_from_utf8(asset.at("manifest").at("source").get<std::string>()),
session.config().project_root));
assets_dirty = true;
}
status = state == "current"
? "Asset ID copied; drag to viewport or an asset field"
: "Reimport required; select Import to refresh the selected source";
for (const auto& reason : freshness.value("reasons", Json::array()))
report(reason.value("message", "Input changed") + ": " +
reason.value("path", ""));
};
keep.insert(row.id);
}
+3 -2
View File
@@ -120,7 +120,8 @@ Json Session::assets_list() const {
try {
const auto id = path_to_utf8(entry.path().filename());
const auto manifest = assets_.current_manifest(id);
list.push_back({{"id", id}, {"manifest", manifest}});
list.push_back(
{{"id", id}, {"manifest", manifest}, {"freshness", assets_.freshness(id)}});
} catch (const std::exception& error) {
list.push_back(
{{"id", path_to_utf8(entry.path().filename())}, {"error", error.what()}});
@@ -375,7 +376,7 @@ void Session::register_commands() {
return Json{{"settings", value}, {"revision", sha256(value.dump())}};
});
commands_.add(
"faset_assets", "List imported asset manifests and resource identities.",
"faset_assets", "List imported assets with source/dependency/recipe freshness and reasons.",
schema(Json::object()), [&](const Json&) { return assets_list(); }, true);
commands_.add(
"faset_import",
+99 -10
View File
@@ -55,12 +55,14 @@ struct Image {
struct Batch {
std::uint32_t first{}, count{};
const Texture* texture{};
std::array<float, 4> clip_rect{};
};
constexpr std::uint32_t shadow_size = 1024;
} // namespace
struct Renderer::Impl {
RendererConfig config;
SDL_Window* window{};
std::string offscreen_clipboard;
bool sdl{}, close{}, dirty_swapchain{};
std::uint32_t width{}, height{};
VkInstance instance{};
@@ -90,6 +92,8 @@ struct Renderer::Impl {
VkSampler shadow_sampler{}, color_sampler{};
VkPipelineLayout pipeline_layout{};
VkPipeline pipeline{}, ui_pipeline{}, shadow_pipeline{}, sprite_pipeline{};
PFN_vkCmdBeginDebugUtilsLabelEXT begin_gpu_label{};
PFN_vkCmdEndDebugUtilsLabelEXT end_gpu_label{};
struct GpuTexture {
Image image;
VkDescriptorSet descriptor{};
@@ -359,13 +363,24 @@ struct Renderer::Impl {
check(vkEnumerateInstanceLayerProperties(&count, nullptr), "Enumerate layers");
std::vector<VkLayerProperties> layers(count);
check(vkEnumerateInstanceLayerProperties(&count, layers.data()), "Enumerate layers");
bool validation = c.validation && std::any_of(layers.begin(), layers.end(), [](auto& p) {
return std::strcmp(p.layerName, "VK_LAYER_KHRONOS_validation") == 0;
});
check(vkEnumerateInstanceExtensionProperties(nullptr, &count, nullptr),
"Enumerate instance extensions");
std::vector<VkExtensionProperties> instance_extensions(count);
check(vkEnumerateInstanceExtensionProperties(nullptr, &count, instance_extensions.data()),
"Enumerate instance extensions");
const bool debug_utils =
std::any_of(instance_extensions.begin(), instance_extensions.end(), [](const auto& p) {
return std::strcmp(p.extensionName, VK_EXT_DEBUG_UTILS_EXTENSION_NAME) == 0;
});
bool validation =
c.validation && debug_utils && std::any_of(layers.begin(), layers.end(), [](auto& p) {
return std::strcmp(p.layerName, "VK_LAYER_KHRONOS_validation") == 0;
});
statistics.validation_enabled = validation;
if (c.validation && !validation)
std::cerr << "[Faset] Vulkan validation layer not installed; diagnostics disabled.\n";
if (validation)
std::cerr << "[Faset] Vulkan validation layer/debug-utils unavailable; validation "
"disabled.\n";
if (debug_utils)
extensions.push_back(VK_EXT_DEBUG_UTILS_EXTENSION_NAME);
VkApplicationInfo app{};
app.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO;
@@ -473,6 +488,13 @@ struct Renderer::Impl {
}
check(vkCreateDevice(physical, &di, nullptr, &device), "Create Vulkan device");
vkGetDeviceQueue(device, queue_family, 0, &queue);
if (debug_utils) {
begin_gpu_label = reinterpret_cast<PFN_vkCmdBeginDebugUtilsLabelEXT>(
vkGetDeviceProcAddr(device, "vkCmdBeginDebugUtilsLabelEXT"));
end_gpu_label = reinterpret_cast<PFN_vkCmdEndDebugUtilsLabelEXT>(
vkGetDeviceProcAddr(device, "vkCmdEndDebugUtilsLabelEXT"));
}
statistics.gpu_labels_enabled = begin_gpu_label && end_gpu_label;
VkCommandPoolCreateInfo pi{};
pi.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO;
pi.queueFamilyIndex = queue_family;
@@ -1000,7 +1022,7 @@ struct Renderer::Impl {
}
void render(const Snapshot& snapshot) {
auto start = std::chrono::steady_clock::now();
statistics.draw_calls = statistics.culled_meshes = 0;
statistics.draw_calls = statistics.culled_meshes = statistics.gpu_label_count = 0;
bool can_present = surface != VK_NULL_HANDLE;
if (surface) {
// A capture may render between normal event-loop iterations. Keep the window
@@ -1033,6 +1055,9 @@ struct Renderer::Impl {
for (const auto& sprite : snapshot.sprites)
if (sprite.texture)
upload_texture(sprite.texture);
for (const auto& triangles : snapshot.ui_triangles)
if (triangles.texture)
upload_texture(triangles.texture);
std::vector<GpuVertex> data;
std::vector<Batch> scene_batches, shadow_batches, sprite_batches, ui_batches;
for (const auto& item : snapshot.draws) {
@@ -1123,6 +1148,24 @@ struct Renderer::Impl {
if (data.size() > text_first)
ui_batches.push_back(
{text_first, static_cast<std::uint32_t>(data.size() - text_first), white.get()});
for (const auto& triangles : snapshot.ui_triangles) {
if (triangles.vertices.size() % 3 != 0)
throw std::invalid_argument("UI triangle list must contain complete triangles");
const auto first = static_cast<std::uint32_t>(data.size());
for (const auto& source : triangles.vertices) {
GpuVertex vertex{};
vertex.clip[0] = source.position[0] / float(width) * 2 - 1;
vertex.clip[1] = source.position[1] / float(height) * 2 - 1;
vertex.clip[3] = 1;
std::copy(source.color.begin(), source.color.end(), vertex.color);
std::copy(source.uv.begin(), source.uv.end(), vertex.uv);
vertex.material[0] = triangles.texture && triangles.texture->srgb ? 1.f : 0.f;
data.push_back(vertex);
}
ui_batches.push_back({first, static_cast<std::uint32_t>(triangles.vertices.size()),
triangles.texture ? triangles.texture.get() : white.get(),
triangles.clip_rect});
}
statistics.vertices = static_cast<std::uint32_t>(data.size());
auto byte_count = std::max<std::size_t>(sizeof(GpuVertex), data.size() * sizeof(GpuVertex));
if (vertices.size < byte_count) {
@@ -1185,7 +1228,33 @@ struct Renderer::Impl {
vkCmdSetScissor(command, 0, 1, &scissor);
};
RenderGraph graph;
graph.add("ShadowMap", {}, {"shadow"}, [&] {
auto add_pass = [&](std::string name, std::vector<std::string> reads,
std::vector<std::string> writes, RenderGraph::Callback callback) {
const auto label_name = name;
graph.add(std::move(name), std::move(reads), std::move(writes),
[this, label_name, callback = std::move(callback)] {
if (statistics.gpu_labels_enabled) {
VkDebugUtilsLabelEXT label{};
label.sType = VK_STRUCTURE_TYPE_DEBUG_UTILS_LABEL_EXT;
label.pLabelName = label_name.c_str();
label.color[0] = .25f;
label.color[1] = .65f;
label.color[2] = .9f;
label.color[3] = 1.f;
begin_gpu_label(command, &label);
++statistics.gpu_label_count;
}
struct EndLabel {
Impl& renderer;
~EndLabel() {
if (renderer.statistics.gpu_labels_enabled)
renderer.end_gpu_label(renderer.command);
}
} end{*this};
callback();
});
};
add_pass("ShadowMap", {}, {"shadow"}, [&] {
transition(command, shadow, VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL,
VK_IMAGE_ASPECT_DEPTH_BIT);
VkRenderingAttachmentInfo attachment{};
@@ -1214,7 +1283,7 @@ struct Renderer::Impl {
transition(command, shadow, VK_IMAGE_LAYOUT_DEPTH_READ_ONLY_OPTIMAL,
VK_IMAGE_ASPECT_DEPTH_BIT);
});
graph.add("ForwardAndUI", {"shadow"}, {"color", "depth"}, [&] {
add_pass("ForwardAndUI", {"shadow"}, {"color", "depth"}, [&] {
transition(command, color, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
VK_IMAGE_ASPECT_COLOR_BIT);
transition(command, depth, VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL,
@@ -1281,6 +1350,20 @@ struct Renderer::Impl {
VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 0,
sizeof(push), &push);
for (auto batch : ui_batches) {
VkRect2D scissor{{0, 0}, {width, height}};
if (batch.clip_rect[2] > 0 && batch.clip_rect[3] > 0) {
const auto& clip = batch.clip_rect;
const float x = std::clamp(clip[0], 0.f, float(width));
const float y = std::clamp(clip[1], 0.f, float(height));
const float right = std::clamp(clip[0] + clip[2], x, float(width));
const float bottom = std::clamp(clip[1] + clip[3], y, float(height));
scissor.offset = {static_cast<int>(x), static_cast<int>(y)};
scissor.extent = {static_cast<unsigned>(right) - static_cast<unsigned>(x),
static_cast<unsigned>(bottom) - static_cast<unsigned>(y)};
}
if (!scissor.extent.width || !scissor.extent.height)
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);
@@ -1289,7 +1372,7 @@ struct Renderer::Impl {
}
vkCmdEndRendering(command);
});
graph.add("Readback", {"color"}, {"capture"}, [&] {
add_pass("Readback", {"color"}, {"capture"}, [&] {
transition(command, color, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
VK_IMAGE_ASPECT_COLOR_BIT);
VkBufferImageCopy copy{};
@@ -1299,7 +1382,7 @@ struct Renderer::Impl {
readback.handle, 1, &copy);
});
if (swap_index)
graph.add("Presentation", {"color"}, {"swapchain"}, [&] {
add_pass("Presentation", {"color"}, {"swapchain"}, [&] {
auto index = *swap_index;
transition(command, swap_images[index], swap_layouts[index],
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_ASPECT_COLOR_BIT);
@@ -1487,10 +1570,16 @@ void Renderer::set_text_input_area(float x, float y, float width, float height)
throw std::runtime_error(SDL_GetError());
}
void Renderer::set_clipboard(const std::string& text) {
if (!impl_->window) {
impl_->offscreen_clipboard = text;
return;
}
if (!SDL_SetClipboardText(text.c_str()))
throw std::runtime_error(SDL_GetError());
}
std::string Renderer::clipboard() const {
if (!impl_->window)
return impl_->offscreen_clipboard;
char* text = SDL_GetClipboardText();
if (!text)
return {};
+35 -1
View File
@@ -420,15 +420,49 @@ struct Context::Impl {
if (ime_enabled)
ime_enabled(false);
}
void reveal(Widget& widget) {
// Reveal inside-out: an offscreen nested scroller still needs its own
// contents positioned before an outer scroller brings it into view.
auto* branch = &widget;
for (auto* parent = widget.parent; parent; branch = parent, parent = parent->parent) {
if (!parent->layout.scroll || parent->kind == Kind::Row || branch->layout.absolute)
continue;
const float padding = parent->layout.padding * scale;
const float top = parent->rect.y + padding;
const float extent = std::max(0.f, parent->rect.height - 2 * padding);
if (extent <= 0)
continue;
float delta = 0;
if (widget.rect.y < top || widget.rect.height > extent)
delta = widget.rect.y - top;
else if (widget.rect.y + widget.rect.height > top + extent)
delta = widget.rect.y + widget.rect.height - top - extent;
const float scroll = std::clamp(parent->scroll_y + delta, 0.f,
std::max(0.f, parent->content_height - extent));
if (scroll != parent->scroll_y) {
parent->scroll_y = scroll;
arrange(root, {0, 0, width, height}, {0, 0, width, height});
}
}
}
bool focus(const std::string& id) {
auto* widget = find(id);
if (!widget || !focusable(*widget))
return false;
if (focused == id)
if (focused == id) {
reveal(*widget);
if (field(*widget) && ime_rectangle)
ime_rectangle(widget->rect);
return true;
}
if (!commit())
return false;
// A commit callback may reconcile the retained tree.
widget = find(id);
if (!widget || !focusable(*widget))
return false;
focused = id;
reveal(*widget);
if (field(*widget)) {
auto& edit = edits[id];
const auto text = widget->kind == Kind::NumberField