Checkpoint 4: harden native paths, authoring workflows and Player lifecycle
This commit is contained in:
@@ -19,9 +19,9 @@ void valid_id(const std::string& id) {
|
||||
throw std::runtime_error("Invalid AssetId");
|
||||
}
|
||||
std::vector<std::byte> read_bytes(const fs::path& path) {
|
||||
std::ifstream file(path, std::ios::binary | std::ios::ate);
|
||||
std::ifstream file(faset::native_io_path(path), std::ios::binary | std::ios::ate);
|
||||
if (!file)
|
||||
throw std::runtime_error("Cannot read cooked file: " + path.string());
|
||||
throw std::runtime_error("Cannot read cooked file: " + faset::path_to_utf8(path));
|
||||
auto length = file.tellg();
|
||||
if (length < 0 || static_cast<std::uint64_t>(length) > 1024ull * 1024 * 1024)
|
||||
throw std::runtime_error("Cooked file exceeds 1 GiB limit");
|
||||
@@ -42,7 +42,7 @@ Json read_json(const fs::path& path) {
|
||||
fs::path cooked_path(const fs::path& directory, const std::string& name) {
|
||||
if (name.empty())
|
||||
throw std::runtime_error("Empty cooked path");
|
||||
return faset::project_path(directory, fs::path(name));
|
||||
return faset::project_path(directory, faset::path_from_utf8(name));
|
||||
}
|
||||
void validate_generation(const fs::path& directory, const Json& manifest) {
|
||||
if (manifest.at("schema_version") != 1)
|
||||
@@ -142,7 +142,7 @@ CookedAsset AssetStore::load_asset(const std::string& id) const {
|
||||
const auto m = read_json(directory / "manifest.json");
|
||||
validate_generation(directory, m);
|
||||
if (m.at("asset_id").get<std::string>() != id ||
|
||||
m.at("generation").get<std::string>() != directory.filename().string())
|
||||
m.at("generation").get<std::string>() != faset::path_to_utf8(directory.filename()))
|
||||
throw std::runtime_error("Cooked asset identity does not match its generation");
|
||||
CookedAsset asset;
|
||||
asset.asset_id = m.at("asset_id");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include <cgltf.h>
|
||||
#include <faset/assets/asset_pipeline.hpp>
|
||||
#include <faset/core/hash.hpp>
|
||||
#include <faset/core/io.hpp>
|
||||
// Import validation owns a private decoder; Player's decoder remains a separate
|
||||
// binary boundary.
|
||||
#define STB_IMAGE_IMPLEMENTATION
|
||||
@@ -63,33 +64,33 @@ void valid_id(const std::string& id) {
|
||||
throw std::runtime_error("Invalid AssetId");
|
||||
}
|
||||
std::vector<std::byte> read_bytes(const fs::path& path) {
|
||||
std::ifstream file(path, std::ios::binary | std::ios::ate);
|
||||
std::ifstream file(faset::native_io_path(path), std::ios::binary | std::ios::ate);
|
||||
if (!file)
|
||||
throw std::runtime_error("Cannot read: " + path.string());
|
||||
throw std::runtime_error("Cannot read: " + faset::path_to_utf8(path));
|
||||
const auto length = file.tellg();
|
||||
if (length < 0 || static_cast<std::uint64_t>(length) > 1024ull * 1024 * 1024)
|
||||
throw std::runtime_error("Input exceeds 1 GiB limit: " + path.string());
|
||||
throw std::runtime_error("Input exceeds 1 GiB limit: " + faset::path_to_utf8(path));
|
||||
std::vector<std::byte> data(static_cast<std::size_t>(length));
|
||||
file.seekg(0);
|
||||
if (!data.empty() &&
|
||||
!file.read(reinterpret_cast<char*>(data.data()), static_cast<std::streamsize>(data.size())))
|
||||
throw std::runtime_error("Short read: " + path.string());
|
||||
throw std::runtime_error("Short read: " + faset::path_to_utf8(path));
|
||||
return data;
|
||||
}
|
||||
void write_bytes(const fs::path& path, const std::vector<std::byte>& data) {
|
||||
fs::create_directories(path.parent_path());
|
||||
std::ofstream out(path, std::ios::binary | std::ios::trunc);
|
||||
std::ofstream out(faset::native_io_path(path), std::ios::binary | std::ios::trunc);
|
||||
if (!out || (!data.empty() && !out.write(reinterpret_cast<const char*>(data.data()),
|
||||
static_cast<std::streamsize>(data.size()))))
|
||||
throw std::runtime_error("Cannot write: " + path.string());
|
||||
throw std::runtime_error("Cannot write: " + faset::path_to_utf8(path));
|
||||
out.close();
|
||||
if (!out)
|
||||
throw std::runtime_error("Cannot close: " + path.string());
|
||||
throw std::runtime_error("Cannot close: " + faset::path_to_utf8(path));
|
||||
}
|
||||
Json read_json(const fs::path& path) {
|
||||
std::ifstream in(path);
|
||||
std::ifstream in(faset::native_io_path(path));
|
||||
if (!in)
|
||||
throw std::runtime_error("Cannot read JSON: " + path.string());
|
||||
throw std::runtime_error("Cannot read JSON: " + faset::path_to_utf8(path));
|
||||
return Json::parse(in);
|
||||
}
|
||||
void write_json(const fs::path& path, const Json& value) {
|
||||
@@ -105,9 +106,10 @@ void atomic_json(const fs::path& path, const Json& value) {
|
||||
try {
|
||||
write_json(temporary, value);
|
||||
#ifdef _WIN32
|
||||
if (!MoveFileExW(temporary.c_str(), path.c_str(),
|
||||
if (!MoveFileExW(faset::native_io_path(temporary).c_str(),
|
||||
faset::native_io_path(path).c_str(),
|
||||
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH))
|
||||
throw std::runtime_error("Atomic replace failed: " + path.string());
|
||||
throw std::runtime_error("Atomic replace failed: " + faset::path_to_utf8(path));
|
||||
#else
|
||||
fs::rename(temporary, path);
|
||||
#endif
|
||||
@@ -177,7 +179,7 @@ std::vector<std::byte> decode_data_uri(const std::string& uri) {
|
||||
fs::path external_path(const fs::path& source, const std::string& uri) {
|
||||
if (uri.find("://") != std::string::npos)
|
||||
throw std::runtime_error("Network URI is not an import dependency: " + uri);
|
||||
const fs::path relative = uri_decode(uri);
|
||||
const fs::path relative = faset::path_from_utf8(uri_decode(uri));
|
||||
if (relative.is_absolute())
|
||||
throw std::runtime_error("glTF URI must be relative");
|
||||
return (source.parent_path() / relative).lexically_normal();
|
||||
@@ -306,13 +308,15 @@ void validate_generation(const fs::path& directory, const Json& manifest) {
|
||||
if (manifest.at("schema_version") != 1)
|
||||
throw std::runtime_error("Unsupported asset manifest version");
|
||||
for (const auto& file : manifest.at("files")) {
|
||||
const fs::path relative = file.at("path").get<std::string>();
|
||||
if (relative.is_absolute() || relative.string().find("..") != std::string::npos)
|
||||
const fs::path 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 cooked file path");
|
||||
auto bytes = read_bytes(directory / relative);
|
||||
if (bytes.size() != file.at("size").get<std::size_t>() ||
|
||||
hash_bytes(bytes) != file.at("sha256").get<std::string>())
|
||||
throw std::runtime_error("Corrupt cooked file: " + relative.string());
|
||||
throw std::runtime_error("Corrupt cooked file: " +
|
||||
faset::generic_path_to_utf8(relative));
|
||||
}
|
||||
}
|
||||
Json material_json(const Material& m) {
|
||||
@@ -387,8 +391,9 @@ ImportResult AssetPipeline::import_asset(const ImportRequest& request, ImportJob
|
||||
valid_id(bundle_asset_id);
|
||||
bool found_payload = false;
|
||||
for (const auto& file : bundle.at("files")) {
|
||||
const auto relative = fs::path(file.at("path").get<std::string>());
|
||||
if (relative.is_absolute() || relative.string().find("..") != std::string::npos)
|
||||
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");
|
||||
const auto candidate = logical_source.parent_path() / relative;
|
||||
const auto bytes = read_bytes(candidate);
|
||||
@@ -407,7 +412,8 @@ ImportResult AssetPipeline::import_asset(const ImportRequest& request, ImportJob
|
||||
}
|
||||
const auto source_bytes = source == logical_source ? logical_bytes : payload_snapshot;
|
||||
const auto source_hash = hash_bytes(source_bytes);
|
||||
const auto sidecar = fs::path(logical_source.string() + ".faset-import.json");
|
||||
auto sidecar = logical_source;
|
||||
sidecar += ".faset-import.json";
|
||||
Json metadata = fs::exists(sidecar) ? read_json(sidecar) : Json::object();
|
||||
if (!metadata.is_object() ||
|
||||
(!metadata.empty() && metadata.value("schema_version", 0) != 1))
|
||||
@@ -431,14 +437,15 @@ ImportResult AssetPipeline::import_asset(const ImportRequest& request, ImportJob
|
||||
Json previous =
|
||||
fs::exists(asset_root / "current.json") ? current_manifest(result.asset_id) : Json();
|
||||
if (!previous.is_null()) {
|
||||
const auto previous_source = fs::path(previous.at("source").get<std::string>());
|
||||
const auto previous_source =
|
||||
faset::path_from_utf8(previous.at("source").get<std::string>());
|
||||
if (previous_source != logical_source && fs::exists(previous_source))
|
||||
throw std::runtime_error("Duplicate AssetId: previous source still exists");
|
||||
}
|
||||
Dependencies dependencies;
|
||||
CookedAsset asset;
|
||||
asset.asset_id = result.asset_id;
|
||||
auto extension = source.extension().string();
|
||||
auto extension = faset::path_to_utf8(source.extension());
|
||||
std::transform(extension.begin(), extension.end(), extension.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
const bool standalone_image =
|
||||
@@ -514,7 +521,7 @@ ImportResult AssetPipeline::import_asset(const ImportRequest& request, ImportJob
|
||||
data->buffers[i].data_free_method = cgltf_data_free_method_none;
|
||||
}
|
||||
}
|
||||
if (cgltf_load_buffers(&options, data.get(), source.string().c_str()) !=
|
||||
if (cgltf_load_buffers(&options, data.get(), faset::path_to_utf8(source).c_str()) !=
|
||||
cgltf_result_success)
|
||||
throw std::runtime_error("Cannot load glTF buffers");
|
||||
if (cgltf_validate(data.get()) != cgltf_result_success)
|
||||
@@ -710,8 +717,8 @@ ImportResult AssetPipeline::import_asset(const ImportRequest& request, ImportJob
|
||||
Json manifest{{"schema_version", 1},
|
||||
{"asset_id", result.asset_id},
|
||||
{"generation", result.generation},
|
||||
{"source", logical_source.string()},
|
||||
{"payload_source", source.string()},
|
||||
{"source", faset::path_to_utf8(logical_source)},
|
||||
{"payload_source", faset::path_to_utf8(source)},
|
||||
{"source_sha256", source_hash},
|
||||
{"importer", recipe_version},
|
||||
{"settings", settings},
|
||||
@@ -772,6 +779,19 @@ ImportResult AssetPipeline::import_asset(const ImportRequest& request, ImportJob
|
||||
if (!published_ids.contains(old.get<std::string>()))
|
||||
result.removed_output_ids.push_back(old.get<std::string>());
|
||||
result.manifest = manifest;
|
||||
result.previous_generation =
|
||||
previous.is_null() ? "" : previous.at("generation").get<std::string>();
|
||||
if ((!request.expected_generation.empty() &&
|
||||
request.expected_generation != result.generation) ||
|
||||
(!request.expected_active_generation.empty() &&
|
||||
request.expected_active_generation != result.previous_generation)) {
|
||||
result.status = ImportStatus::conflict;
|
||||
result.diagnostics.push_back(
|
||||
"Import changed since review; reimport and review the current removed outputs. "
|
||||
"Active generation preserved");
|
||||
fs::remove_all(stage);
|
||||
return result;
|
||||
}
|
||||
if (!result.removed_output_ids.empty() && !request.allow_removed_outputs) {
|
||||
result.status = ImportStatus::conflict;
|
||||
result.diagnostics.push_back(
|
||||
@@ -808,7 +828,7 @@ ImportResult AssetPipeline::import_asset(const ImportRequest& request, ImportJob
|
||||
throw Cancelled{};
|
||||
atomic_json(asset_root / "current.json", {{"schema_version", 1},
|
||||
{"generation", result.generation},
|
||||
{"source", logical_source.string()}});
|
||||
{"source", faset::path_to_utf8(logical_source)}});
|
||||
result.status = ImportStatus::succeeded;
|
||||
job.report(1, "complete");
|
||||
} catch (const Cancelled&) {
|
||||
@@ -826,15 +846,15 @@ ImportResult AssetPipeline::import_asset(const ImportRequest& request, ImportJob
|
||||
}
|
||||
Json AssetPipeline::overrides(const std::string& id) const {
|
||||
const auto source = current_manifest(id).at("source").get<std::string>();
|
||||
const auto path = fs::path(source + ".faset-overrides.json");
|
||||
const auto path = faset::path_from_utf8(source + ".faset-overrides.json");
|
||||
return fs::exists(path) ? read_json(path) : Json::object();
|
||||
}
|
||||
void AssetPipeline::set_overrides(const std::string& id, const Json& values) {
|
||||
if (!values.is_object())
|
||||
throw std::runtime_error("Overrides must be an object keyed by stable output IDs");
|
||||
std::lock_guard lock(writer_mutex);
|
||||
atomic_json(
|
||||
fs::path(current_manifest(id).at("source").get<std::string>() + ".faset-overrides.json"),
|
||||
values);
|
||||
atomic_json(faset::path_from_utf8(current_manifest(id).at("source").get<std::string>() +
|
||||
".faset-overrides.json"),
|
||||
values);
|
||||
}
|
||||
} // namespace faset::assets
|
||||
|
||||
@@ -233,7 +233,7 @@ Json AuthoringService::summary(const State& value, bool include_data) const {
|
||||
{"name", value.data.at("name")},
|
||||
{"revision", value.revision},
|
||||
{"dirty", sha256(value.data.dump()) != value.saved_hash},
|
||||
{"path", value.path.generic_string()},
|
||||
{"path", generic_path_to_utf8(value.path)},
|
||||
{"can_undo", !value.undo.empty()},
|
||||
{"can_redo", !value.redo.empty()}};
|
||||
if (include_data)
|
||||
@@ -245,7 +245,7 @@ void AuthoringService::journal(const State& value) const {
|
||||
(value.data.at("id").get<std::string>() + ".json")),
|
||||
{{"format", "faset.recovery"},
|
||||
{"version", 1},
|
||||
{"path", value.path.generic_string()},
|
||||
{"path", generic_path_to_utf8(value.path)},
|
||||
{"revision", value.revision},
|
||||
{"saved_hash", value.saved_hash},
|
||||
{"disk_hash", value.disk_hash},
|
||||
@@ -468,7 +468,7 @@ void AuthoringService::apply(Json& scene, const Json& command) {
|
||||
}
|
||||
if (op == "template.source_set") {
|
||||
const auto source = command.at("source").get<std::string>();
|
||||
project_path(root_, source);
|
||||
project_path(root_, path_from_utf8(source));
|
||||
(*found)["source"] = source;
|
||||
return;
|
||||
}
|
||||
@@ -593,7 +593,7 @@ Json AuthoringService::recover(const std::string& id,
|
||||
validate_scene(candidate.data, schemas_);
|
||||
require(candidate.data.at("id") == id, "recovery.id",
|
||||
"Recovery ID does not match its document");
|
||||
candidate.path = record.at("path").get<std::string>();
|
||||
candidate.path = path_from_utf8(record.at("path").get<std::string>());
|
||||
candidate.saved_hash = record.value("saved_hash", std::string());
|
||||
candidate.disk_hash = record.value("disk_hash", std::string());
|
||||
candidate.revision = record.value("revision", std::uint64_t(0));
|
||||
@@ -642,7 +642,7 @@ Json AuthoringService::recovery_documents() const {
|
||||
value.value("saved_hash", std::string())}});
|
||||
} catch (const std::exception&) {
|
||||
result.push_back({{"error", "Invalid recovery record"},
|
||||
{"file", entry.path().filename().string()}});
|
||||
{"file", path_to_utf8(entry.path().filename())}});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
||||
+4
-3
@@ -3,6 +3,7 @@
|
||||
#include <cstdint>
|
||||
#include <faset/core/error.hpp>
|
||||
#include <faset/core/hash.hpp>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <fstream>
|
||||
#include <vector>
|
||||
|
||||
@@ -101,8 +102,8 @@ std::string sha256(std::span<const std::byte> bytes) {
|
||||
return digest.finish();
|
||||
}
|
||||
std::string sha256_file(const std::filesystem::path& path) {
|
||||
std::ifstream stream(path, std::ios::binary);
|
||||
require(bool(stream), "io.open", "Cannot open file for hashing: " + path.string());
|
||||
std::ifstream stream(native_io_path(path), std::ios::binary);
|
||||
require(bool(stream), "io.open", "Cannot open file for hashing: " + path_to_utf8(path));
|
||||
Digest digest;
|
||||
std::array<char, 65536> buffer{};
|
||||
while (stream) {
|
||||
@@ -110,7 +111,7 @@ std::string sha256_file(const std::filesystem::path& path) {
|
||||
digest.update(
|
||||
std::as_bytes(std::span(buffer.data(), static_cast<std::size_t>(stream.gcount()))));
|
||||
}
|
||||
require(stream.eof(), "io.read", "Cannot read file for hashing: " + path.string());
|
||||
require(stream.eof(), "io.read", "Cannot read file for hashing: " + path_to_utf8(path));
|
||||
return digest.finish();
|
||||
}
|
||||
} // namespace faset
|
||||
|
||||
+95
-11
@@ -2,8 +2,10 @@
|
||||
#include <faset/core/error.hpp>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <mutex>
|
||||
#include <random>
|
||||
#include <vector>
|
||||
#ifdef _WIN32
|
||||
#define NOMINMAX
|
||||
#include <windows.h>
|
||||
@@ -13,6 +15,86 @@
|
||||
#endif
|
||||
|
||||
namespace faset {
|
||||
namespace {
|
||||
void validate_utf8_path(std::string_view text) {
|
||||
for (std::size_t i = 0; i < text.size();) {
|
||||
const auto first = static_cast<unsigned char>(text[i++]);
|
||||
require(first != 0, "path.encoding", "NUL in filesystem path");
|
||||
if (first < 0x80)
|
||||
continue;
|
||||
unsigned remaining = first >= 0xc2 && first <= 0xdf ? 1
|
||||
: first >= 0xe0 && first <= 0xef ? 2
|
||||
: first >= 0xf0 && first <= 0xf4 ? 3
|
||||
: 0;
|
||||
require(remaining != 0 && i + remaining <= text.size(), "path.encoding",
|
||||
"Invalid UTF-8 path");
|
||||
const unsigned minimum = remaining == 1 ? 0x80 : remaining == 2 ? 0x800 : 0x10000;
|
||||
unsigned code = first & ((1u << (6 - remaining)) - 1u);
|
||||
while (remaining--) {
|
||||
const auto next = static_cast<unsigned char>(text[i++]);
|
||||
require((next & 0xc0) == 0x80, "path.encoding", "Invalid UTF-8 path");
|
||||
code = (code << 6) | (next & 0x3f);
|
||||
}
|
||||
require(code >= minimum && code <= 0x10ffff && !(code >= 0xd800 && code <= 0xdfff),
|
||||
"path.encoding", "Invalid UTF-8 path");
|
||||
}
|
||||
}
|
||||
std::string utf8_bytes(const std::u8string& value) {
|
||||
return {reinterpret_cast<const char*>(value.data()), value.size()};
|
||||
}
|
||||
} // namespace
|
||||
std::filesystem::path path_from_utf8(std::string_view text) {
|
||||
validate_utf8_path(text);
|
||||
return std::filesystem::path(std::u8string(text.begin(), text.end()));
|
||||
}
|
||||
std::string path_to_utf8(const std::filesystem::path& path) {
|
||||
return utf8_bytes(path.u8string());
|
||||
}
|
||||
std::string generic_path_to_utf8(const std::filesystem::path& path) {
|
||||
return utf8_bytes(path.generic_u8string());
|
||||
}
|
||||
std::filesystem::path native_io_path(const std::filesystem::path& path) {
|
||||
#ifdef _WIN32
|
||||
auto normalized = std::filesystem::absolute(path).lexically_normal();
|
||||
normalized.make_preferred();
|
||||
const auto& native = normalized.native();
|
||||
if (native.starts_with(LR"(\\?\)") || native.starts_with(LR"(\\.\)"))
|
||||
return normalized;
|
||||
if (native.starts_with(LR"(\\)"))
|
||||
return std::filesystem::path(std::wstring(LR"(\\?\UNC\)") + native.substr(2));
|
||||
return std::filesystem::path(std::wstring(LR"(\\?\)") + native);
|
||||
#else
|
||||
return path;
|
||||
#endif
|
||||
}
|
||||
#ifdef _WIN32
|
||||
int run_utf8_main(int argc, wchar_t** argv, int (*entry)(int, char**)) noexcept {
|
||||
try {
|
||||
std::vector<std::string> arguments;
|
||||
arguments.reserve(static_cast<std::size_t>(argc));
|
||||
for (int i = 0; i < argc; ++i) {
|
||||
const auto length = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, argv[i], -1,
|
||||
nullptr, 0, nullptr, nullptr);
|
||||
require(length > 0, "cli.encoding", "Invalid Unicode command-line argument");
|
||||
std::string value(static_cast<std::size_t>(length), '\0');
|
||||
require(WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, argv[i], -1, value.data(),
|
||||
length, nullptr, nullptr) == length,
|
||||
"cli.encoding", "Cannot convert command-line argument to UTF-8");
|
||||
value.pop_back();
|
||||
arguments.push_back(std::move(value));
|
||||
}
|
||||
std::vector<char*> pointers;
|
||||
pointers.reserve(arguments.size() + 1);
|
||||
for (auto& value : arguments)
|
||||
pointers.push_back(value.data());
|
||||
pointers.push_back(nullptr);
|
||||
return entry(argc, pointers.data());
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << "Command line failed: " << error.what() << '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
std::string new_id() {
|
||||
static std::mutex mutex;
|
||||
static std::random_device random;
|
||||
@@ -36,27 +118,29 @@ std::string new_id() {
|
||||
return result;
|
||||
}
|
||||
std::string read_text(const std::filesystem::path& path) {
|
||||
std::ifstream stream(path, std::ios::binary);
|
||||
require(bool(stream), "io.open", "Cannot open file: " + path.string());
|
||||
std::ifstream stream(native_io_path(path), std::ios::binary);
|
||||
require(bool(stream), "io.open", "Cannot open file: " + path_to_utf8(path));
|
||||
std::string value((std::istreambuf_iterator<char>(stream)), {});
|
||||
require(!stream.bad(), "io.read", "Cannot read file: " + path.string());
|
||||
require(!stream.bad(), "io.read", "Cannot read file: " + path_to_utf8(path));
|
||||
return value;
|
||||
}
|
||||
Json read_json(const std::filesystem::path& path) {
|
||||
try {
|
||||
return Json::parse(read_text(path));
|
||||
} catch (const Json::exception& error) {
|
||||
throw Error("format.json", "Invalid JSON in " + path.string(), {{"reason", error.what()}});
|
||||
throw Error("format.json", "Invalid JSON in " + path_to_utf8(path),
|
||||
{{"reason", error.what()}});
|
||||
}
|
||||
}
|
||||
void atomic_write(const std::filesystem::path& path, std::string_view bytes) {
|
||||
const auto parent = path.has_parent_path() ? path.parent_path() : std::filesystem::path(".");
|
||||
std::filesystem::create_directories(parent);
|
||||
const auto temporary = parent / (path.filename().string() + ".tmp-" + new_id());
|
||||
std::filesystem::create_directories(native_io_path(parent));
|
||||
auto temporary = path;
|
||||
temporary += ".tmp-" + new_id(); // Append ASCII to the native path without a narrow round trip.
|
||||
try {
|
||||
#ifdef _WIN32
|
||||
HANDLE file = CreateFileW(temporary.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_NEW,
|
||||
FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
HANDLE file = CreateFileW(native_io_path(temporary).c_str(), GENERIC_WRITE, 0, nullptr,
|
||||
CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
require(file != INVALID_HANDLE_VALUE, "io.create", "Cannot create temporary file");
|
||||
bool ok = true;
|
||||
std::size_t offset = 0;
|
||||
@@ -73,9 +157,9 @@ void atomic_write(const std::filesystem::path& path, std::string_view bytes) {
|
||||
ok = FlushFileBuffers(file) && ok;
|
||||
CloseHandle(file);
|
||||
require(ok, "io.write", "Cannot flush temporary file");
|
||||
require(MoveFileExW(temporary.c_str(), path.c_str(),
|
||||
require(MoveFileExW(native_io_path(temporary).c_str(), native_io_path(path).c_str(),
|
||||
MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0,
|
||||
"io.replace", "Cannot publish file: " + path.string());
|
||||
"io.replace", "Cannot publish file: " + path_to_utf8(path));
|
||||
#else
|
||||
const int fd = ::open(temporary.c_str(), O_WRONLY | O_CREAT | O_EXCL, 0644);
|
||||
require(fd >= 0, "io.create", "Cannot create temporary file");
|
||||
@@ -104,7 +188,7 @@ void atomic_write(const std::filesystem::path& path, std::string_view bytes) {
|
||||
#endif
|
||||
} catch (...) {
|
||||
std::error_code ignored;
|
||||
std::filesystem::remove(temporary, ignored);
|
||||
std::filesystem::remove(native_io_path(temporary), ignored);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
+39
-25
@@ -131,6 +131,41 @@ struct Process::Impl {
|
||||
::close(output);
|
||||
#endif
|
||||
}
|
||||
#ifndef _WIN32
|
||||
bool collect_exit() {
|
||||
// WNOWAIT keeps the exited leader's PID reserved until its owned group has
|
||||
// been stopped. Never signal a PGID after reaping the leader: it may be reused.
|
||||
siginfo_t information{};
|
||||
int result;
|
||||
do {
|
||||
result =
|
||||
::waitid(P_PID, static_cast<id_t>(pid), &information, WEXITED | WNOHANG | WNOWAIT);
|
||||
} while (result < 0 && errno == EINTR);
|
||||
if (result < 0) {
|
||||
if (errno == ECHILD) {
|
||||
running = false;
|
||||
pid = -1;
|
||||
}
|
||||
throw std::runtime_error("Cannot collect child process");
|
||||
}
|
||||
if (information.si_pid == 0)
|
||||
return false;
|
||||
::kill(-pid, SIGKILL);
|
||||
int status{};
|
||||
pid_t reaped;
|
||||
do {
|
||||
reaped = ::waitpid(pid, &status, 0);
|
||||
} while (reaped < 0 && errno == EINTR);
|
||||
running = false;
|
||||
pid = -1;
|
||||
if (reaped < 0)
|
||||
throw std::runtime_error("Cannot reap child process");
|
||||
exit_code = WIFEXITED(status) ? WEXITSTATUS(status)
|
||||
: WIFSIGNALED(status) ? 128 + WTERMSIG(status)
|
||||
: 1;
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
ProcessPoll poll() {
|
||||
std::string text;
|
||||
char buffer[8192];
|
||||
@@ -171,20 +206,8 @@ struct Process::Impl {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (running) {
|
||||
int status{};
|
||||
pid_t result;
|
||||
do {
|
||||
result = ::waitpid(pid, &status, WNOHANG);
|
||||
} while (result < 0 && errno == EINTR);
|
||||
if (result == pid) {
|
||||
running = false;
|
||||
exit_code = WIFEXITED(status) ? WEXITSTATUS(status)
|
||||
: WIFSIGNALED(status) ? 128 + WTERMSIG(status)
|
||||
: 1;
|
||||
} else if (result < 0)
|
||||
throw std::runtime_error("Cannot collect child process");
|
||||
}
|
||||
if (running)
|
||||
collect_exit();
|
||||
#endif
|
||||
if (!running) {
|
||||
#ifdef _WIN32
|
||||
@@ -234,18 +257,8 @@ struct Process::Impl {
|
||||
::kill(-pid, SIGTERM);
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(200);
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
int status{};
|
||||
auto result = ::waitpid(pid, &status, WNOHANG);
|
||||
if (result == pid) {
|
||||
running = false;
|
||||
exit_code = WIFEXITED(status) ? WEXITSTATUS(status) : 128 + WTERMSIG(status);
|
||||
::kill(-pid, SIGKILL);
|
||||
if (collect_exit())
|
||||
return;
|
||||
}
|
||||
if (result < 0 && errno != EINTR) {
|
||||
running = false;
|
||||
return;
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||
}
|
||||
::kill(-pid, SIGKILL);
|
||||
@@ -253,6 +266,7 @@ struct Process::Impl {
|
||||
while (::waitpid(pid, &status, 0) < 0 && errno == EINTR) {
|
||||
}
|
||||
running = false;
|
||||
pid = -1;
|
||||
exit_code = 130;
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ std::set<std::string> asset_references(const Json& scene) {
|
||||
void copy_required_file(const fs::path& source, const fs::path& target) {
|
||||
if (!fs::is_regular_file(source) || fs::is_symlink(source))
|
||||
throw std::runtime_error("Required package file missing or not a regular file: " +
|
||||
source.string());
|
||||
path_to_utf8(source));
|
||||
fs::create_directories(target.parent_path());
|
||||
fs::copy_file(source, target, fs::copy_options::overwrite_existing);
|
||||
}
|
||||
@@ -248,9 +248,9 @@ struct BuildService::Impl {
|
||||
fs::create_directories(native_directory);
|
||||
std::vector<std::string> arguments = {config.cmake,
|
||||
"-S",
|
||||
config.engine_root.string(),
|
||||
path_to_utf8(config.engine_root),
|
||||
"-B",
|
||||
native_directory.string(),
|
||||
path_to_utf8(native_directory),
|
||||
"-G",
|
||||
config.generator,
|
||||
"-DCMAKE_BUILD_TYPE=" + configuration,
|
||||
@@ -260,7 +260,7 @@ struct BuildService::Impl {
|
||||
"-DFASET_BUILD_RUNTIME=ON",
|
||||
"-DFASET_BUILD_ASSETS=ON",
|
||||
"-DFASET_GAMEPLAY_SOURCE_DIR=" +
|
||||
(config.project_root / "Scripts").string()};
|
||||
path_to_utf8(config.project_root / "Scripts")};
|
||||
bool compiler_overridden = false;
|
||||
for (const auto& arg : config.configure_arguments)
|
||||
if (arg.starts_with("-DCMAKE_CXX_COMPILER="))
|
||||
@@ -268,11 +268,11 @@ struct BuildService::Impl {
|
||||
if (!compiler_overridden) {
|
||||
#ifdef _WIN32
|
||||
auto compiler = find_executable("clang-cl");
|
||||
arguments.push_back("-DCMAKE_C_COMPILER=" + compiler.string());
|
||||
arguments.push_back("-DCMAKE_CXX_COMPILER=" + compiler.string());
|
||||
arguments.push_back("-DCMAKE_C_COMPILER=" + path_to_utf8(compiler));
|
||||
arguments.push_back("-DCMAKE_CXX_COMPILER=" + path_to_utf8(compiler));
|
||||
#else
|
||||
arguments.push_back("-DCMAKE_C_COMPILER=" + find_executable("clang").string());
|
||||
arguments.push_back("-DCMAKE_CXX_COMPILER=" + find_executable("clang++").string());
|
||||
arguments.push_back("-DCMAKE_C_COMPILER=" + path_to_utf8(find_executable("clang")));
|
||||
arguments.push_back("-DCMAKE_CXX_COMPILER=" + path_to_utf8(find_executable("clang++")));
|
||||
#endif
|
||||
}
|
||||
arguments.insert(arguments.end(), config.configure_arguments.begin(),
|
||||
@@ -281,7 +281,7 @@ struct BuildService::Impl {
|
||||
run(job, std::move(arguments), config.project_root);
|
||||
checkpoint(job, "Compiling and linking Player", .25);
|
||||
run(job,
|
||||
{config.cmake, "--build", native_directory.string(), "--config", configuration,
|
||||
{config.cmake, "--build", path_to_utf8(native_directory), "--config", configuration,
|
||||
"--parallel", "4", "--target", "faset_player", "faset_schema_exporter"},
|
||||
config.project_root);
|
||||
checkpoint(job, "Exporting gameplay schema", .58);
|
||||
@@ -292,7 +292,8 @@ struct BuildService::Impl {
|
||||
fs::create_directories(staging);
|
||||
try {
|
||||
const auto schema_file = staging / "schema.json";
|
||||
run(job, {exporter.string(), "--output", schema_file.string()}, config.project_root);
|
||||
run(job, {path_to_utf8(exporter), "--output", path_to_utf8(schema_file)},
|
||||
config.project_root);
|
||||
auto schema = read_json(schema_file);
|
||||
if (schema.value("format", "") != "faset.schema" || schema.value("version", 0) != 1 ||
|
||||
!schema.contains("types") || !schema.at("types").is_array())
|
||||
@@ -324,11 +325,11 @@ struct BuildService::Impl {
|
||||
atomic_write_json(config.cache_root / "last_build.json",
|
||||
{{"generation", job.status.id}, {"fingerprint", fingerprint}});
|
||||
return {{"generation", job.status.id},
|
||||
{"directory", generation.string()},
|
||||
{"build_directory", native_directory.string()},
|
||||
{"directory", path_to_utf8(generation)},
|
||||
{"build_directory", path_to_utf8(native_directory)},
|
||||
{"configuration", configuration},
|
||||
{"player", (generation / ("faset_player" + executable_suffix())).string()},
|
||||
{"schema", (generation / "schema.json").string()},
|
||||
{"player", path_to_utf8(generation / ("faset_player" + executable_suffix()))},
|
||||
{"schema", path_to_utf8(generation / "schema.json")},
|
||||
{"fingerprint", fingerprint}};
|
||||
} catch (...) {
|
||||
std::error_code error;
|
||||
@@ -346,7 +347,7 @@ struct BuildService::Impl {
|
||||
{executable.parent_path(), native_directory, native_directory / configuration})
|
||||
if (fs::is_directory(root))
|
||||
for (const auto& entry : fs::directory_iterator(root)) {
|
||||
auto extension = entry.path().extension().string();
|
||||
auto extension = path_to_utf8(entry.path().extension());
|
||||
for (auto& c : extension)
|
||||
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
if (entry.is_regular_file() && extension == ".dll")
|
||||
@@ -356,7 +357,8 @@ struct BuildService::Impl {
|
||||
(void)job;
|
||||
#else
|
||||
const auto output =
|
||||
run(job, {find_executable("ldd").string(), executable.string()}, config.project_root);
|
||||
run(job, {path_to_utf8(find_executable("ldd")), path_to_utf8(executable)},
|
||||
config.project_root);
|
||||
if (output.find("not found") != std::string::npos)
|
||||
throw std::runtime_error("Player has unresolved shared library dependencies");
|
||||
// SDL/physics/gameplay are linked statically. glibc/libstdc++/Vulkan are the host baseline.
|
||||
@@ -427,8 +429,8 @@ struct BuildService::Impl {
|
||||
fs::rename(staging, directory);
|
||||
atomic_write_json(config.cache_root / "last_cook.json", {{"generation", digest}});
|
||||
return {{"generation", digest},
|
||||
{"scene", (directory / "scene.fscene").string()},
|
||||
{"directory", directory.string()}};
|
||||
{"scene", path_to_utf8(directory / "scene.fscene")},
|
||||
{"directory", path_to_utf8(directory)}};
|
||||
} catch (...) {
|
||||
std::error_code error;
|
||||
fs::remove_all(staging, error);
|
||||
@@ -451,13 +453,14 @@ struct BuildService::Impl {
|
||||
for (const auto& entry : fs::directory_iterator(source)) {
|
||||
if (!entry.is_regular_file())
|
||||
continue;
|
||||
auto filename = entry.path().filename().string();
|
||||
auto filename = path_to_utf8(entry.path().filename());
|
||||
std::string upper = filename;
|
||||
for (auto& c : upper)
|
||||
c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
|
||||
if (upper.starts_with("LICENSE") || upper.starts_with("COPYING") ||
|
||||
upper.starts_with("NOTICE")) {
|
||||
copy_required_file(entry.path(), destination / name / filename);
|
||||
copy_required_file(entry.path(),
|
||||
destination / name / entry.path().filename());
|
||||
copied = true;
|
||||
}
|
||||
}
|
||||
@@ -487,7 +490,7 @@ struct BuildService::Impl {
|
||||
"generations" / generation);
|
||||
auto target = destination / "assets" / id / "generations" / generation;
|
||||
for (const auto& file : manifest.at("files")) {
|
||||
auto relative = fs::path(file.at("path").get<std::string>());
|
||||
auto relative = path_from_utf8(file.at("path").get<std::string>());
|
||||
copy_required_file(project_path(source_directory, relative),
|
||||
project_path(target, relative));
|
||||
}
|
||||
@@ -508,7 +511,8 @@ struct BuildService::Impl {
|
||||
for (const auto& id : asset_references(job.scene))
|
||||
job.asset_manifests[id] = source.current_manifest(id);
|
||||
const auto built = build(job, true);
|
||||
validate_component_types(job.scene, read_json(built.at("schema").get<std::string>()));
|
||||
validate_component_types(job.scene,
|
||||
read_json(path_from_utf8(built.at("schema").get<std::string>())));
|
||||
auto output = fs::absolute(job.output);
|
||||
if (output.empty())
|
||||
throw std::runtime_error("An export destination is required");
|
||||
@@ -519,7 +523,7 @@ struct BuildService::Impl {
|
||||
try {
|
||||
checkpoint(job, "Cooking export snapshot", .72);
|
||||
write_cooked_scene(staging / "scene.fscene", job.scene);
|
||||
auto build_directory = fs::path(built.at("directory").get<std::string>());
|
||||
auto build_directory = path_from_utf8(built.at("directory").get<std::string>());
|
||||
copy_required_file(build_directory / ("faset_player" + executable_suffix()),
|
||||
staging / ("faset_player" + executable_suffix()));
|
||||
for (const auto* shader : {"vertexMain.spv", "fragmentMain.spv", "shadowMain.spv",
|
||||
@@ -528,7 +532,7 @@ struct BuildService::Impl {
|
||||
copy_required_file(build_directory / "shaders" / shader,
|
||||
staging / "shaders" / shader);
|
||||
for (const auto& entry : fs::directory_iterator(build_directory)) {
|
||||
auto extension = entry.path().extension().string();
|
||||
auto extension = path_to_utf8(entry.path().extension());
|
||||
for (auto& c : extension)
|
||||
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
if (entry.is_regular_file() && extension == ".dll")
|
||||
@@ -536,7 +540,8 @@ struct BuildService::Impl {
|
||||
}
|
||||
checkpoint(job, "Packaging assets and notices", .80);
|
||||
package_assets(job, staging);
|
||||
package_notices(staging / "Notices", built.at("build_directory").get<std::string>());
|
||||
package_notices(staging / "Notices",
|
||||
path_from_utf8(built.at("build_directory").get<std::string>()));
|
||||
atomic_write(
|
||||
staging / "README.txt",
|
||||
"Run faset_player" + executable_suffix() +
|
||||
@@ -553,8 +558,9 @@ struct BuildService::Impl {
|
||||
checkpoint(job, "Validating packaged Player", .90);
|
||||
auto validation_output =
|
||||
run(job,
|
||||
{(staging / ("faset_player" + executable_suffix())).string(), "--validate",
|
||||
"--scene", (staging / "scene.fscene").string(), "--assets", staging.string()},
|
||||
{path_to_utf8(staging / ("faset_player" + executable_suffix())), "--validate",
|
||||
"--scene", path_to_utf8(staging / "scene.fscene"), "--assets",
|
||||
path_to_utf8(staging)},
|
||||
staging);
|
||||
Json files = Json::array();
|
||||
for (const auto& entry : fs::recursive_directory_iterator(staging)) {
|
||||
@@ -562,7 +568,7 @@ struct BuildService::Impl {
|
||||
throw std::runtime_error("Export contains a symlink");
|
||||
if (entry.is_regular_file())
|
||||
files.push_back(
|
||||
{{"path", entry.path().lexically_relative(staging).generic_string()},
|
||||
{{"path", generic_path_to_utf8(entry.path().lexically_relative(staging))},
|
||||
{"sha256", sha256_file(entry.path())},
|
||||
{"size", entry.file_size()}});
|
||||
}
|
||||
@@ -613,15 +619,16 @@ struct BuildService::Impl {
|
||||
{"version", 1},
|
||||
{"generation", job.status.id},
|
||||
{"directory", "generations/" + job.status.id}});
|
||||
return {{"directory", generation.string()},
|
||||
{"executable", (generation / ("faset_player" + executable_suffix())).string()},
|
||||
{"manifest", (generation / "manifest.json").string()},
|
||||
{"generation", job.status.id},
|
||||
{"build", built},
|
||||
{"schema", built.at("schema")},
|
||||
{"player", built.at("player")},
|
||||
{"build_directory", built.at("build_directory")},
|
||||
{"configuration", built.at("configuration")}};
|
||||
return {
|
||||
{"directory", path_to_utf8(generation)},
|
||||
{"executable", path_to_utf8(generation / ("faset_player" + executable_suffix()))},
|
||||
{"manifest", path_to_utf8(generation / "manifest.json")},
|
||||
{"generation", job.status.id},
|
||||
{"build", built},
|
||||
{"schema", built.at("schema")},
|
||||
{"player", built.at("player")},
|
||||
{"build_directory", built.at("build_directory")},
|
||||
{"configuration", built.at("configuration")}};
|
||||
} catch (...) {
|
||||
std::error_code error;
|
||||
fs::remove_all(staging, error);
|
||||
|
||||
@@ -68,9 +68,9 @@ Json Commands::call(const std::string& name, const Json& arguments) {
|
||||
Json Commands::resolved_scene(const std::string& id) const {
|
||||
const auto result = authoring::resolve_templates(
|
||||
authoring_.query(id).at("scene"), authoring_.schemas(), [&](const std::string& path) {
|
||||
const auto relative = std::filesystem::path(path).lexically_normal();
|
||||
const auto relative = path_from_utf8(path).lexically_normal();
|
||||
for (const auto& document : authoring_.documents())
|
||||
if (document.at("path") == relative.generic_string())
|
||||
if (document.at("path") == generic_path_to_utf8(relative))
|
||||
return authoring_.query(document.at("id")).at("scene");
|
||||
return read_json(project_path(authoring_.root(), relative));
|
||||
});
|
||||
@@ -92,7 +92,7 @@ Commands::Commands(authoring::AuthoringService& authoring) : authoring_(authorin
|
||||
"Open a scene relative to the project. Set recover=true to load its saved recovery "
|
||||
"journal.",
|
||||
object_schema({{"path", text}, {"recover", boolean}}, {"path"}), [&](const Json& args) {
|
||||
return authoring_.open(args.at("path").get<std::string>(),
|
||||
return authoring_.open(path_from_utf8(args.at("path").get<std::string>()),
|
||||
args.value("recover", false));
|
||||
});
|
||||
add(
|
||||
@@ -104,7 +104,8 @@ Commands::Commands(authoring::AuthoringService& authoring) : authoring_(authorin
|
||||
add("faset_document_save",
|
||||
"Atomically save an authoring document. Refuses to overwrite an externally modified file.",
|
||||
object_schema({{"document", text}, {"path", text}}, {"document"}), [&](const Json& args) {
|
||||
return authoring_.save(args.at("document"), args.value("path", std::string()));
|
||||
return authoring_.save(args.at("document"),
|
||||
path_from_utf8(args.value("path", std::string())));
|
||||
});
|
||||
add(
|
||||
"faset_schema",
|
||||
|
||||
+203
-67
@@ -174,6 +174,7 @@ struct EditorUI::Impl {
|
||||
std::uint64_t shown_revision = std::numeric_limits<std::uint64_t>::max();
|
||||
std::map<std::string, std::uint64_t> edit_revisions;
|
||||
std::map<std::string, Json> preview_fields;
|
||||
std::map<std::string, std::string> import_retries;
|
||||
std::filesystem::path layout_path;
|
||||
std::filesystem::path theme_source_path, layout_source_path;
|
||||
std::string attempted_theme, attempted_layout, applied_theme, applied_layout,
|
||||
@@ -196,6 +197,14 @@ struct EditorUI::Impl {
|
||||
std::uint64_t gizmo_revision = 0;
|
||||
std::string gizmo_component;
|
||||
ui::Rect viewport;
|
||||
float ui_scale = 1;
|
||||
unsigned layout_width = 0, layout_height = 0;
|
||||
float logical_width() const {
|
||||
return float(renderer.width()) / ui_scale;
|
||||
}
|
||||
float logical_height() const {
|
||||
return float(renderer.height()) / ui_scale;
|
||||
}
|
||||
Vec3 gizmo_origin{};
|
||||
render::Vec2 gizmo_drag_start{}, gizmo_drag_end{};
|
||||
float gizmo_world_length = 1;
|
||||
@@ -253,12 +262,13 @@ struct EditorUI::Impl {
|
||||
document = session.authoring()
|
||||
.create("Untitled", session.project().value("dimension", 3))
|
||||
.at("id");
|
||||
ui_scale = std::clamp(renderer.display_scale(), .5f, 4.f);
|
||||
refresh();
|
||||
}
|
||||
void persist_layout() {
|
||||
dock.set_size("scene", ui.find("scene_panel")->rect.width);
|
||||
dock.set_size("inspector", ui.find("inspector_panel")->rect.width);
|
||||
dock.set_size("bottom", ui.find("bottom_panel")->rect.height);
|
||||
dock.set_size("scene", ui.find("scene_panel")->rect.width / ui_scale);
|
||||
dock.set_size("inspector", ui.find("inspector_panel")->rect.width / ui_scale);
|
||||
dock.set_size("bottom", ui.find("bottom_panel")->rect.height / ui_scale);
|
||||
try {
|
||||
dock.save(layout_path);
|
||||
} catch (const std::exception& e) {
|
||||
@@ -897,7 +907,8 @@ struct EditorUI::Impl {
|
||||
}
|
||||
if (!open)
|
||||
try {
|
||||
source_scene = read_json(project_path(session.config().project_root, source));
|
||||
source_scene = read_json(
|
||||
project_path(session.config().project_root, path_from_utf8(source)));
|
||||
} catch (...) {
|
||||
return source;
|
||||
}
|
||||
@@ -983,7 +994,8 @@ struct EditorUI::Impl {
|
||||
}
|
||||
void instance_scene(const std::string& path) {
|
||||
try {
|
||||
auto data = read_json(project_path(session.config().project_root, path));
|
||||
auto data =
|
||||
read_json(project_path(session.config().project_root, path_from_utf8(path)));
|
||||
authoring::validate_scene(data, session.authoring().schemas());
|
||||
if (data.at("id") == document)
|
||||
throw std::runtime_error("A scene cannot instance itself");
|
||||
@@ -1019,7 +1031,7 @@ struct EditorUI::Impl {
|
||||
subtree.insert(item.at("id").get<std::string>()).second || changed;
|
||||
}
|
||||
auto result = call("faset_document_create",
|
||||
{{"name", std::filesystem::path(path).stem().string()},
|
||||
{{"name", path_to_utf8(path_from_utf8(path).stem())},
|
||||
{"dimension", current.at("scene").value("dimension", 3)}});
|
||||
if (result.is_null())
|
||||
return;
|
||||
@@ -1075,9 +1087,9 @@ struct EditorUI::Impl {
|
||||
const bool image = manifest.is_object() && manifest.value("kind", std::string()) == "image";
|
||||
const auto name =
|
||||
manifest.is_object()
|
||||
? std::filesystem::path(manifest.value("source", std::string("Imported asset")))
|
||||
.stem()
|
||||
.string()
|
||||
? path_to_utf8(
|
||||
path_from_utf8(manifest.value("source", std::string("Imported asset")))
|
||||
.stem())
|
||||
: "Imported asset";
|
||||
auto object = authoring::make_entity(session.authoring().schemas(), name);
|
||||
const auto entity_id = object.at("id").get<std::string>();
|
||||
@@ -1218,7 +1230,7 @@ struct EditorUI::Impl {
|
||||
std::function<void(Json, int)> append_group = [&](Json path, int depth) {
|
||||
const auto source = instance_source(path);
|
||||
auto& row = tree.add(Kind::TreeRow, "instance-" + path.dump(),
|
||||
"[T] " + std::filesystem::path(source).filename().string());
|
||||
"[T] " + path_to_utf8(path_from_utf8(source).filename()));
|
||||
row.indent = depth;
|
||||
row.selected = instance_selection == path;
|
||||
row.on_click = [this, path](Widget&) { select_instance(path); };
|
||||
@@ -1378,7 +1390,7 @@ struct EditorUI::Impl {
|
||||
const auto path = e->at("origin").at("path");
|
||||
const auto object = e->at("origin").at("object").get<std::string>();
|
||||
label(body, "object-origin",
|
||||
"Source: " + std::filesystem::path(instance_source(path)).filename().string());
|
||||
"Source: " + path_to_utf8(path_from_utf8(instance_source(path)).filename()));
|
||||
keep.insert("object-origin");
|
||||
button(body, "object-open-source", "Open source",
|
||||
[this, path, object] { open_template_source(path, object); });
|
||||
@@ -1565,9 +1577,8 @@ struct EditorUI::Impl {
|
||||
break;
|
||||
if (!entry.is_regular_file())
|
||||
continue;
|
||||
auto relative =
|
||||
std::filesystem::relative(entry.path(), session.config().project_root)
|
||||
.generic_string();
|
||||
auto relative = generic_path_to_utf8(
|
||||
std::filesystem::relative(entry.path(), session.config().project_root));
|
||||
if (relative.find(".faset-") != std::string::npos)
|
||||
continue;
|
||||
if (!asset_filter.empty() && relative.find(asset_filter) == std::string::npos)
|
||||
@@ -1602,11 +1613,10 @@ struct EditorUI::Impl {
|
||||
}
|
||||
for (const auto& asset : assets) {
|
||||
const auto id = asset.at("id").get<std::string>();
|
||||
const auto name = asset.contains("manifest")
|
||||
? std::filesystem::path(asset["manifest"].value("source", id))
|
||||
.filename()
|
||||
.string()
|
||||
: id;
|
||||
const auto name =
|
||||
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);
|
||||
row.layout.height = 25;
|
||||
row.indent = 1;
|
||||
@@ -1624,6 +1634,8 @@ struct EditorUI::Impl {
|
||||
trim_children(list, keep);
|
||||
}
|
||||
void refresh_bottom() {
|
||||
const auto jobs_response = call("faset_jobs");
|
||||
const auto all_jobs = jobs_response.is_null() ? Json::array() : jobs_response.at("jobs");
|
||||
for (const auto& panel : session.plugin_panels()) {
|
||||
const auto panel_id = panel.at("id").get<std::string>();
|
||||
const auto dock_id = "plugin-" + panel_id;
|
||||
@@ -1674,8 +1686,6 @@ struct EditorUI::Impl {
|
||||
ui.find("console-items")->visible = active_bottom == "console";
|
||||
ui.find("job-items")->visible = active_bottom == "jobs";
|
||||
ui.find("conflict-items")->visible = active_bottom == "conflicts";
|
||||
ui.find("tab-conflicts")->text =
|
||||
"Conflicts (" + std::to_string(template_conflicts.size()) + ")";
|
||||
auto& conflict_list = *ui.find("conflict-items");
|
||||
std::set<std::string> conflict_keep;
|
||||
std::size_t conflict_index = 0;
|
||||
@@ -1687,7 +1697,7 @@ struct EditorUI::Impl {
|
||||
const auto path = conflict.at("instance_path");
|
||||
auto& text = row.add(
|
||||
Kind::Label, id + "-text",
|
||||
code + " · " + std::filesystem::path(instance_source(path)).filename().string());
|
||||
code + " · " + path_to_utf8(path_from_utf8(instance_source(path)).filename()));
|
||||
text.layout.flex = 1;
|
||||
button(
|
||||
row, id + "-source", "Open source", [this, path] { open_template_source(path); },
|
||||
@@ -1716,9 +1726,113 @@ struct EditorUI::Impl {
|
||||
}
|
||||
conflict_keep.insert(id);
|
||||
}
|
||||
std::size_t import_conflicts = 0;
|
||||
for (const auto& job : all_jobs) {
|
||||
if (job.value("kind", std::string()) != "import" ||
|
||||
job.value("state", std::string()) != "conflict")
|
||||
continue;
|
||||
const auto job_id = job.at("id").get<std::string>();
|
||||
const auto& result = job.at("result");
|
||||
const auto request = job.value("request", Json::object());
|
||||
bool pending = false, superseded = false;
|
||||
std::string retry_error;
|
||||
if (const auto found = import_retries.find(job_id); found != import_retries.end())
|
||||
for (const auto& retry : all_jobs)
|
||||
if (retry.at("id") == found->second) {
|
||||
const auto state = retry.value("state", std::string());
|
||||
pending = state == "queued" || state == "running";
|
||||
superseded = state == "succeeded" || state == "conflict";
|
||||
if (state == "failed" || state == "cancelled")
|
||||
retry_error = retry.value("error", std::string("Retry cancelled"));
|
||||
}
|
||||
for (const auto& asset : assets)
|
||||
if (asset.at("id") == result.value("asset_id", std::string()) &&
|
||||
asset.contains("manifest") &&
|
||||
asset.at("manifest").at("generation") ==
|
||||
result.value("generation", std::string()))
|
||||
superseded = true;
|
||||
if (superseded)
|
||||
continue;
|
||||
++import_conflicts;
|
||||
const auto id = "import-conflict-" + job_id;
|
||||
auto& group = conflict_list.add(Kind::Column, id);
|
||||
group.layout.padding = 6;
|
||||
group.layout.gap = 3;
|
||||
conflict_keep.insert(id);
|
||||
label(group, id + "-title", "Import removal: " + request.value("path", job_id));
|
||||
label(group, id + "-note",
|
||||
"The previous asset stays active. Update affected references before accepting.");
|
||||
label(group, id + "-generation",
|
||||
"Reviewed generation: " + result.value("generation", std::string()));
|
||||
auto diagnostic = job.value("error", std::string());
|
||||
std::replace(diagnostic.begin(), diagnostic.end(), '\n', ' ');
|
||||
label(group, id + "-diagnostic", diagnostic);
|
||||
const auto removed = result.value("removed_output_ids", Json::array());
|
||||
auto& outputs = group.add(Kind::Column, id + "-outputs");
|
||||
outputs.layout.height = std::min(150.f, std::max(26.f, float(removed.size()) * 26));
|
||||
outputs.layout.scroll = true;
|
||||
outputs.layout.gap = 0;
|
||||
std::set<std::string> output_keep;
|
||||
for (const auto& output : removed) {
|
||||
const auto output_id = output.get<std::string>();
|
||||
std::string name;
|
||||
for (const auto& asset : assets)
|
||||
if (asset.at("id") == result.value("asset_id", std::string()) &&
|
||||
asset.contains("manifest"))
|
||||
for (const auto* collection : {"nodes", "meshes", "materials", "textures"})
|
||||
for (const auto& item :
|
||||
asset.at("manifest").value(collection, Json::array()))
|
||||
if (item.at("id") == output_id)
|
||||
name = item.value("name", std::string());
|
||||
const auto output_widget = id + "-output-" + output_id;
|
||||
label(outputs, output_widget, output_id + (name.empty() ? "" : " / " + name));
|
||||
outputs.find(output_widget)->layout.height = 26;
|
||||
output_keep.insert(output_widget);
|
||||
}
|
||||
trim_children(outputs, output_keep);
|
||||
auto& actions = group.add(Kind::Row, id + "-actions");
|
||||
actions.layout.height = 30;
|
||||
const auto retry = [this, job_id, request, result](bool accept) {
|
||||
Json arguments = {{"path", request.at("path")}, {"allow_removed_outputs", accept}};
|
||||
if (request.contains("settings") && !request.at("settings").is_null())
|
||||
arguments["settings"] = request.at("settings");
|
||||
if (accept) {
|
||||
arguments["expected_generation"] = result.at("generation");
|
||||
arguments["expected_active_generation"] = result.at("previous_generation");
|
||||
}
|
||||
auto submitted = call("faset_import", arguments);
|
||||
if (!submitted.is_null()) {
|
||||
import_retries[job_id] = submitted.at("job");
|
||||
status =
|
||||
accept
|
||||
? "Publishing the reviewed import; source changes require a new review"
|
||||
: "Reimporting source for a fresh review";
|
||||
}
|
||||
};
|
||||
button(
|
||||
actions, id + "-retry", "Reimport / review again", [retry] { retry(false); }, 185)
|
||||
.enabled = !pending && request.contains("path");
|
||||
button(
|
||||
actions, id + "-accept", "Accept reviewed removal", [retry] { retry(true); }, 190)
|
||||
.enabled = !pending && request.contains("path") && !removed.empty();
|
||||
button(
|
||||
actions, id + "-copy", "Copy removed IDs",
|
||||
[this, removed] { renderer.set_clipboard(removed.dump(2)); }, 145);
|
||||
auto detail = pending ? "Import in progress; the previous generation remains active."
|
||||
: "Accepting removes these outputs. Scene references are not "
|
||||
"remapped automatically.";
|
||||
label(group, id + "-detail", detail);
|
||||
if (!retry_error.empty()) {
|
||||
std::replace(retry_error.begin(), retry_error.end(), '\n', ' ');
|
||||
label(group, id + "-error", retry_error);
|
||||
} else
|
||||
group.remove(id + "-error");
|
||||
}
|
||||
ui.find("tab-conflicts")->text =
|
||||
"Conflicts (" + std::to_string(template_conflicts.size() + import_conflicts) + ")";
|
||||
if (conflict_keep.empty()) {
|
||||
label(conflict_list, "conflicts-empty",
|
||||
"No template conflicts. Missing targets preserve their overrides here.");
|
||||
"No template or import conflicts. Missing targets preserve their data here.");
|
||||
conflict_keep.insert("conflicts-empty");
|
||||
}
|
||||
trim_children(conflict_list, conflict_keep);
|
||||
@@ -1738,27 +1852,28 @@ struct EditorUI::Impl {
|
||||
keep.insert("log-empty");
|
||||
}
|
||||
trim_children(console, keep);
|
||||
auto result = call("faset_jobs");
|
||||
auto& jobs = *ui.find("job-items");
|
||||
keep.clear();
|
||||
if (!result.is_null())
|
||||
for (const auto& job : result.at("jobs")) {
|
||||
const auto id = job.at("id").get<std::string>();
|
||||
auto& row = jobs.add(Kind::Row, "job-" + id);
|
||||
row.layout.height = 28;
|
||||
keep.insert(row.id);
|
||||
const auto state = job.value("state", std::string());
|
||||
auto& text =
|
||||
row.add(Kind::Label, "job-text-" + id,
|
||||
job.value("kind", std::string("Job")) + " · " + state + " · " +
|
||||
job.value("stage", std::string()) + " " +
|
||||
std::to_string(int(job.value("progress", 0.0) * 100)) + "%");
|
||||
text.layout.flex = 1;
|
||||
auto& cancel = button(
|
||||
row, "job-cancel-" + id, "Cancel",
|
||||
[this, id] { call("faset_job_cancel", {{"id", id}}); }, 72);
|
||||
cancel.enabled = state == "queued" || state == "running";
|
||||
}
|
||||
for (const auto& job : all_jobs) {
|
||||
const auto id = job.at("id").get<std::string>();
|
||||
auto& row = jobs.add(Kind::Row, "job-" + id);
|
||||
row.layout.height = 28;
|
||||
keep.insert(row.id);
|
||||
const auto state = job.value("state", std::string());
|
||||
auto& text = row.add(Kind::Label, "job-text-" + id,
|
||||
job.value("kind", std::string("Job")) + " · " + state + " · " +
|
||||
job.value("stage", std::string()) + " " +
|
||||
std::to_string(int(job.value("progress", 0.0) * 100)) + "%");
|
||||
text.layout.flex = 1;
|
||||
auto& cancel = button(
|
||||
row, "job-cancel-" + id, "Cancel",
|
||||
[this, id] { call("faset_job_cancel", {{"id", id}}); }, 72);
|
||||
cancel.enabled = state == "queued" || state == "running";
|
||||
auto& review = button(
|
||||
row, "job-review-" + id, "Review removals", [this] { active_bottom = "conflicts"; },
|
||||
135);
|
||||
review.visible = job.value("kind", std::string()) == "import" && state == "conflict";
|
||||
}
|
||||
if (keep.empty()) {
|
||||
label(jobs, "jobs-empty", "No active import, build or export jobs.");
|
||||
keep.insert("jobs-empty");
|
||||
@@ -1788,8 +1903,8 @@ struct EditorUI::Impl {
|
||||
: "Project switching is unavailable while MCP is connected";
|
||||
auto* switching = ui.find("project-switch-dialog");
|
||||
switching->visible = project_switch_warning;
|
||||
switching->layout.x = std::max(0.f, (float(renderer.width()) - 500) * .5f);
|
||||
switching->layout.y = std::max(0.f, (float(renderer.height()) - 220) * .5f);
|
||||
switching->layout.x = std::max(0.f, (logical_width() - 500) * .5f);
|
||||
switching->layout.y = std::max(0.f, (logical_height() - 220) * .5f);
|
||||
for (const auto* id : {"menu-duplicate", "menu-delete"})
|
||||
ui.find(id)->visible = edit;
|
||||
for (const auto* id : {"help-one", "help-two", "help-three"})
|
||||
@@ -1805,7 +1920,7 @@ struct EditorUI::Impl {
|
||||
: 5;
|
||||
auto& command = *ui.find("palette");
|
||||
command.visible = palette;
|
||||
command.layout.x = std::max(0.f, (float(renderer.width()) - 570) / 2);
|
||||
command.layout.x = std::max(0.f, (logical_width() - 570) / 2);
|
||||
command.layout.y = 90;
|
||||
auto& list = *ui.find("palette-list");
|
||||
std::set<std::string> keep;
|
||||
@@ -1855,10 +1970,9 @@ struct EditorUI::Impl {
|
||||
if (++visited > 4000)
|
||||
break;
|
||||
if (entry.is_regular_file() &&
|
||||
entry.path().filename().string().ends_with(".scene.json"))
|
||||
saved.insert(
|
||||
std::filesystem::relative(entry.path(), session.config().project_root)
|
||||
.generic_string());
|
||||
path_to_utf8(entry.path().filename()).ends_with(".scene.json"))
|
||||
saved.insert(generic_path_to_utf8(std::filesystem::relative(
|
||||
entry.path(), session.config().project_root)));
|
||||
}
|
||||
}
|
||||
} catch (const std::exception& error) {
|
||||
@@ -1867,7 +1981,8 @@ struct EditorUI::Impl {
|
||||
std::size_t index = 0;
|
||||
for (const auto& path : saved) {
|
||||
try {
|
||||
const auto scene = read_json(project_path(session.config().project_root, path));
|
||||
const auto scene =
|
||||
read_json(project_path(session.config().project_root, path_from_utf8(path)));
|
||||
if (scene.value("format", "") != "faset.scene" || scene.value("version", 0) != 1)
|
||||
continue;
|
||||
} catch (...) {
|
||||
@@ -1914,10 +2029,10 @@ struct EditorUI::Impl {
|
||||
void refresh_project_settings() {
|
||||
auto* panel = ui.find("project-settings-panel");
|
||||
panel->visible = project_settings_open;
|
||||
panel->layout.width = std::min(610.f, std::max(340.f, float(renderer.width()) - 40));
|
||||
panel->layout.height = std::min(550.f, std::max(300.f, float(renderer.height()) - 40));
|
||||
panel->layout.x = std::max(0.f, (float(renderer.width()) - panel->layout.width) * .5f);
|
||||
panel->layout.y = std::max(0.f, (float(renderer.height()) - panel->layout.height) * .5f);
|
||||
panel->layout.width = std::min(610.f, std::max(340.f, logical_width() - 40));
|
||||
panel->layout.height = std::min(550.f, std::max(300.f, logical_height() - 40));
|
||||
panel->layout.x = std::max(0.f, (logical_width() - panel->layout.width) * .5f);
|
||||
panel->layout.y = std::max(0.f, (logical_height() - panel->layout.height) * .5f);
|
||||
ui.find("project-settings-2d")->selected = project_settings_dimension == 2;
|
||||
ui.find("project-settings-3d")->selected = project_settings_dimension == 3;
|
||||
ui.find("project-settings-error")->text = project_settings_error;
|
||||
@@ -1928,7 +2043,7 @@ struct EditorUI::Impl {
|
||||
void refresh_simulation() {
|
||||
auto& panel = *ui.find("simulation-panel");
|
||||
panel.visible = simulation_open;
|
||||
panel.layout.x = std::max(0.f, (float(renderer.width()) - 470) / 2);
|
||||
panel.layout.x = std::max(0.f, (logical_width() - 470) / 2);
|
||||
panel.layout.y = 90;
|
||||
if (!simulation_open)
|
||||
return;
|
||||
@@ -1986,7 +2101,7 @@ struct EditorUI::Impl {
|
||||
void refresh_recovery() {
|
||||
auto& panel = *ui.find("recovery-panel");
|
||||
panel.visible = !recovery.empty();
|
||||
panel.layout.x = std::max(0.f, (float(renderer.width()) - 470) / 2);
|
||||
panel.layout.x = std::max(0.f, (logical_width() - 470) / 2);
|
||||
panel.layout.y = 100;
|
||||
std::set<std::string> keep;
|
||||
label(panel, "recovery-title", "Unsaved authoring recovery");
|
||||
@@ -2152,11 +2267,16 @@ struct EditorUI::Impl {
|
||||
const render::Color colors[3] = {
|
||||
{.88f, .35f, .34f, 1}, {.38f, .75f, .49f, 1}, {.4f, .58f, .92f, 1}};
|
||||
for (int axis = 0; axis < (is2d ? 2 : 3); ++axis) {
|
||||
line(gizmo_screen[0], gizmo_screen[axis + 1], colors[axis], 2);
|
||||
line(gizmo_screen[0], gizmo_screen[axis + 1], colors[axis], 2 * ui_scale);
|
||||
const auto end = gizmo_screen[axis + 1];
|
||||
if (viewport.contains(end[0], end[1]))
|
||||
rendered.ui_quads.push_back(
|
||||
{end[0] - 4, end[1] - 4, 8, 8, colors[axis], {}, {0, 0, 1, 1}});
|
||||
rendered.ui_quads.push_back({end[0] - 4 * ui_scale,
|
||||
end[1] - 4 * ui_scale,
|
||||
8 * ui_scale,
|
||||
8 * ui_scale,
|
||||
colors[axis],
|
||||
{},
|
||||
{0, 0, 1, 1}});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2196,12 +2316,12 @@ struct EditorUI::Impl {
|
||||
const auto* c = component(*e, "faset.transform");
|
||||
if (!c || c->value("version", 1) != 1)
|
||||
return false;
|
||||
float best = 8;
|
||||
float best = 8 * ui_scale;
|
||||
int axis = -1;
|
||||
for (int i = 0; i < (current.at("scene").value("dimension", 3) == 2 ? 2 : 3); ++i) {
|
||||
auto a = gizmo_screen[0], b = gizmo_screen[i + 1];
|
||||
const auto dx = b[0] - a[0], dy = b[1] - a[1], l = dx * dx + dy * dy;
|
||||
if (l < 16)
|
||||
if (l < 16 * ui_scale * ui_scale)
|
||||
continue;
|
||||
const auto t = std::clamp(((x - a[0]) * dx + (y - a[1]) * dy) / l, 0.f, 1.f);
|
||||
const auto d = std::hypot(x - a[0] - t * dx, y - a[1] - t * dy);
|
||||
@@ -2282,8 +2402,8 @@ struct EditorUI::Impl {
|
||||
if (camera_drag) {
|
||||
const bool is2d = current.at("scene").value("dimension", 3) == 2;
|
||||
if (camera_drag == 3 && !is2d) {
|
||||
yaw -= dx * .008f;
|
||||
pitch = std::clamp(pitch + dy * .008f, -1.5f, 1.5f);
|
||||
yaw -= dx / ui_scale * .008f;
|
||||
pitch = std::clamp(pitch + dy / ui_scale * .008f, -1.5f, 1.5f);
|
||||
} else {
|
||||
const auto right = Vec3{std::cos(yaw), 0, -std::sin(yaw)};
|
||||
const auto up =
|
||||
@@ -2367,7 +2487,7 @@ struct EditorUI::Impl {
|
||||
}
|
||||
}
|
||||
if (event.type == Type::MouseDown && !menu.empty() &&
|
||||
!ui.find("menu-popup")->rect.contains(event.x, event.y) && event.y > 36)
|
||||
!ui.find("menu-popup")->rect.contains(event.x, event.y) && event.y > 36 * ui_scale)
|
||||
menu.clear();
|
||||
if (event.type == Type::MouseMove || event.type == Type::MouseDown) {
|
||||
mouse_x = event.x;
|
||||
@@ -2427,15 +2547,31 @@ struct EditorUI::Impl {
|
||||
}
|
||||
}
|
||||
void frame(const std::vector<render::Event>& events_) {
|
||||
const auto next_scale = std::clamp(renderer.display_scale(), .5f, 4.f);
|
||||
const bool geometry_changed = next_scale != ui_scale || layout_width != renderer.width() ||
|
||||
layout_height != renderer.height();
|
||||
if (next_scale != ui_scale) {
|
||||
camera_drag = 0;
|
||||
if (gizmo_axis >= 0) {
|
||||
const auto field = gizmo_mode == "Move" ? "position"
|
||||
: gizmo_mode == "Rotate" ? "rotation"
|
||||
: "scale";
|
||||
preview_fields.erase(gizmo_component + "/" + field);
|
||||
gizmo_axis = -1;
|
||||
}
|
||||
}
|
||||
ui_scale = next_scale;
|
||||
layout_width = renderer.width();
|
||||
layout_height = renderer.height();
|
||||
session.poll();
|
||||
poll_presentation();
|
||||
refresh();
|
||||
ui.layout(float(renderer.width()), float(renderer.height()));
|
||||
if (rendered.scene_rect[2] == 0)
|
||||
ui.layout(float(renderer.width()), float(renderer.height()), ui_scale);
|
||||
if (rendered.scene_rect[2] == 0 || geometry_changed)
|
||||
build_snapshot();
|
||||
events(events_);
|
||||
refresh();
|
||||
ui.layout(float(renderer.width()), float(renderer.height()));
|
||||
ui.layout(float(renderer.width()), float(renderer.height()), ui_scale);
|
||||
build_snapshot();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -202,7 +202,8 @@ struct PluginManager::Impl {
|
||||
module->owner = this;
|
||||
module->manifest = manifest;
|
||||
module->directory = directory;
|
||||
const auto path = project_path(directory, manifest.at("library").get<std::string>());
|
||||
const auto path =
|
||||
project_path(directory, path_from_utf8(manifest.at("library").get<std::string>()));
|
||||
require(std::filesystem::is_regular_file(path), "plugin.library",
|
||||
"Plugin library is missing");
|
||||
#ifdef _WIN32
|
||||
@@ -307,8 +308,8 @@ void PluginManager::load(const std::filesystem::path& directory) {
|
||||
};
|
||||
for (const auto& entry : std::filesystem::recursive_directory_iterator(directory))
|
||||
if (entry.is_regular_file() &&
|
||||
entry.path().filename().string().ends_with(".faset-plugin.json")) {
|
||||
std::string id = entry.path().filename().string();
|
||||
path_to_utf8(entry.path().filename()).ends_with(".faset-plugin.json")) {
|
||||
std::string id = path_to_utf8(entry.path().filename());
|
||||
try {
|
||||
const auto manifest = read_json(entry.path());
|
||||
id = manifest.at("id");
|
||||
@@ -324,7 +325,8 @@ void PluginManager::load(const std::filesystem::path& directory) {
|
||||
manifest.at("build_fingerprint") == fingerprint(),
|
||||
"plugin.compatibility",
|
||||
"Plugin manifest does not match this Editor SDK; rebuild it");
|
||||
project_path(entry.path().parent_path(), manifest.at("library").get<std::string>());
|
||||
project_path(entry.path().parent_path(),
|
||||
path_from_utf8(manifest.at("library").get<std::string>()));
|
||||
sources.emplace(id, Source{manifest, entry.path().parent_path()});
|
||||
} catch (const std::exception& error) {
|
||||
failure(id, error.what());
|
||||
|
||||
+59
-21
@@ -34,7 +34,7 @@ struct Session::ImportTask {
|
||||
std::shared_ptr<assets::ImportJob> job = std::make_shared<assets::ImportJob>();
|
||||
mutable std::mutex mutex;
|
||||
std::string state = "queued", error;
|
||||
Json result = Json::object();
|
||||
Json result = Json::object(), request = Json::object();
|
||||
Json json() const {
|
||||
std::lock_guard lock(mutex);
|
||||
const auto progress = job->progress();
|
||||
@@ -44,6 +44,7 @@ struct Session::ImportTask {
|
||||
{"stage", progress.stage},
|
||||
{"progress", progress.fraction},
|
||||
{"error", error},
|
||||
{"request", request},
|
||||
{"result", result}};
|
||||
}
|
||||
};
|
||||
@@ -97,13 +98,13 @@ Json Session::project() const {
|
||||
"Project start_scene must be a relative path");
|
||||
const auto scene = value.at("start_scene").get<std::string>();
|
||||
if (!scene.empty())
|
||||
project_path(config_.project_root, scene);
|
||||
project_path(config_.project_root, path_from_utf8(scene));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
return {{"format", "faset.project"},
|
||||
{"version", 1},
|
||||
{"name", config_.project_root.filename().string()},
|
||||
{"name", path_to_utf8(config_.project_root.filename())},
|
||||
{"dimension", 3}};
|
||||
}
|
||||
void Session::scaffold(const std::string& name, int dimension) {
|
||||
@@ -117,12 +118,12 @@ Json Session::assets_list() const {
|
||||
for (const auto& entry : std::filesystem::directory_iterator(directory))
|
||||
if (entry.is_directory()) {
|
||||
try {
|
||||
const auto id = entry.path().filename().string();
|
||||
const auto id = path_to_utf8(entry.path().filename());
|
||||
const auto manifest = assets_.current_manifest(id);
|
||||
list.push_back({{"id", id}, {"manifest", manifest}});
|
||||
} catch (const std::exception& error) {
|
||||
list.push_back(
|
||||
{{"id", entry.path().filename().string()}, {"error", error.what()}});
|
||||
{{"id", path_to_utf8(entry.path().filename())}, {"error", error.what()}});
|
||||
}
|
||||
}
|
||||
return {{"assets", list}};
|
||||
@@ -137,8 +138,8 @@ std::string Session::source_signature() const {
|
||||
std::sort(files.begin(), files.end());
|
||||
std::string contents;
|
||||
for (const auto& file : files)
|
||||
contents +=
|
||||
file.lexically_relative(directory).generic_string() + ":" + sha256_file(file) + "\n";
|
||||
contents += generic_path_to_utf8(file.lexically_relative(directory)) + ":" +
|
||||
sha256_file(file) + "\n";
|
||||
return sha256(contents);
|
||||
}
|
||||
Json Session::schema_status() const {
|
||||
@@ -181,8 +182,8 @@ void Session::launch_player(Json scene, const std::filesystem::path& executable)
|
||||
control_sequence_ = 0;
|
||||
ProcessOptions options;
|
||||
options.arguments = {
|
||||
executable.string(), "--scene", snapshot.string(), "--assets",
|
||||
assets_.cache_root().string(), "--control", control_path_.string()};
|
||||
path_to_utf8(executable), "--scene", path_to_utf8(snapshot), "--assets",
|
||||
path_to_utf8(assets_.cache_root()), "--control", path_to_utf8(control_path_)};
|
||||
options.working_directory = config_.project_root;
|
||||
player_ = std::make_unique<Process>(options);
|
||||
log("Play started in a separate Player process");
|
||||
@@ -194,10 +195,34 @@ void Session::stop_player() {
|
||||
pending_play_scene_ = nullptr;
|
||||
}
|
||||
if (player_) {
|
||||
player_->cancel();
|
||||
const auto result = player_->poll();
|
||||
log(result.output);
|
||||
// Allow the normal Player shutdown path (including gameplay OnDestroy) first.
|
||||
// Keep Stop synchronous and bounded so starting a new Play cannot overlap this one.
|
||||
bool finished = false;
|
||||
try {
|
||||
atomic_write_json(control_path_,
|
||||
{{"sequence", ++control_sequence_}, {"command", "stop"}});
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(500);
|
||||
for (;;) {
|
||||
const auto result = player_->poll();
|
||||
log(result.output);
|
||||
if (!result.running) {
|
||||
finished = true;
|
||||
break;
|
||||
}
|
||||
if (std::chrono::steady_clock::now() >= deadline)
|
||||
break;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
}
|
||||
} catch (const std::exception& error) {
|
||||
log(std::string("Graceful Player stop failed: ") + error.what());
|
||||
}
|
||||
if (!finished) {
|
||||
log("Player did not finish graceful shutdown; terminating the process");
|
||||
player_->cancel();
|
||||
log(player_->poll().output);
|
||||
}
|
||||
player_.reset();
|
||||
control_path_.clear();
|
||||
log("Play stopped; authoring scene unchanged");
|
||||
}
|
||||
}
|
||||
@@ -224,7 +249,7 @@ void Session::poll() {
|
||||
log(value.kind + " completed");
|
||||
if (value.result.contains("schema"))
|
||||
try {
|
||||
load_schema(value.result.at("schema").get<std::string>());
|
||||
load_schema(path_from_utf8(value.result.at("schema").get<std::string>()));
|
||||
// This signature represents the sources submitted with this job, not later
|
||||
// edits.
|
||||
if (value.result.contains("source_signature"))
|
||||
@@ -245,10 +270,9 @@ void Session::poll() {
|
||||
if (value.state == "succeeded" && schema_valid)
|
||||
try {
|
||||
const auto executable =
|
||||
value.result.value("player", (builds_.config().build_directory /
|
||||
executable_name("faset_player"))
|
||||
.string());
|
||||
launch_player(pending_play_scene_, executable);
|
||||
value.result.value("player", path_to_utf8(builds_.config().build_directory /
|
||||
executable_name("faset_player")));
|
||||
launch_player(pending_play_scene_, path_from_utf8(executable));
|
||||
} catch (const std::exception& error) {
|
||||
log(std::string("Play failed: ") + error.what());
|
||||
}
|
||||
@@ -332,7 +356,8 @@ void Session::register_commands() {
|
||||
else {
|
||||
require(field.is_string() && !field.get<std::string>().empty(),
|
||||
"project.start_scene", "Choose a saved scene inside the project");
|
||||
const auto file = project_path(config_.project_root, field.get<std::string>());
|
||||
const auto file = project_path(config_.project_root,
|
||||
path_from_utf8(field.get<std::string>()));
|
||||
require(std::filesystem::is_regular_file(file), "project.start_scene",
|
||||
"Save the start scene before selecting it in Project settings");
|
||||
const auto scene = read_json(file);
|
||||
@@ -358,15 +383,25 @@ void Session::register_commands() {
|
||||
"a cancellable job ID; failure retains the last successful generation.",
|
||||
schema({{"path", text},
|
||||
{"settings", {{"type", "object"}}},
|
||||
{"allow_removed_outputs", boolean}},
|
||||
{"allow_removed_outputs", boolean},
|
||||
{"expected_generation", text},
|
||||
{"expected_active_generation", text}},
|
||||
{"path"}),
|
||||
[&](const Json& args) {
|
||||
assets::ImportRequest request;
|
||||
request.source = project_path(config_.project_root, args.at("path").get<std::string>());
|
||||
request.source = project_path(config_.project_root,
|
||||
path_from_utf8(args.at("path").get<std::string>()));
|
||||
request.settings = args.value("settings", Json(nullptr));
|
||||
request.allow_removed_outputs = args.value("allow_removed_outputs", false);
|
||||
request.expected_generation = args.value("expected_generation", std::string());
|
||||
request.expected_active_generation =
|
||||
args.value("expected_active_generation", std::string());
|
||||
auto task = std::make_shared<ImportTask>();
|
||||
task->id = "import-" + new_id();
|
||||
task->request = {{"path", generic_path_to_utf8(std::filesystem::relative(
|
||||
request.source, config_.project_root))},
|
||||
{"settings", request.settings},
|
||||
{"allow_removed_outputs", request.allow_removed_outputs}};
|
||||
imports_[task->id] = task;
|
||||
workers_.emplace_back([this, task, request] {
|
||||
{
|
||||
@@ -382,6 +417,8 @@ void Session::register_commands() {
|
||||
: "failed";
|
||||
task->result = {{"asset_id", result.asset_id},
|
||||
{"generation", result.generation},
|
||||
{"previous_generation", result.previous_generation},
|
||||
{"removed_output_ids", result.removed_output_ids},
|
||||
{"diagnostics", result.diagnostics},
|
||||
{"cache_hit", result.cache_hit},
|
||||
{"manifest", result.manifest}};
|
||||
@@ -417,7 +454,8 @@ void Session::register_commands() {
|
||||
const auto signature = source_signature();
|
||||
const auto id = builds_.start_export(
|
||||
resolved_or_throw(commands_, args.at("document")),
|
||||
project_path(config_.project_root, args.at("output").get<std::string>()));
|
||||
project_path(config_.project_root,
|
||||
path_from_utf8(args.at("output").get<std::string>())));
|
||||
submitted_sources_[id] = signature;
|
||||
return Json{{"job", id}};
|
||||
});
|
||||
|
||||
@@ -300,6 +300,7 @@ render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSetting
|
||||
out.light_direction = direction(model, {-0.5f, -1, -0.3f});
|
||||
if (auto fields = properties(entity, "sprite"); !fields.is_null()) {
|
||||
render::Sprite sprite;
|
||||
sprite.layer = fields.value("layer", 0);
|
||||
sprite.position = point(model, {0, 0, 0});
|
||||
auto size = vec<2>(fields, "size", {1, 1});
|
||||
float sx = std::hypot(model[0], model[1]), sy = std::hypot(model[4], model[5]);
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
namespace faset::player {
|
||||
nlohmann::json readScene(const std::filesystem::path& path) {
|
||||
if (!std::filesystem::is_regular_file(path))
|
||||
throw std::runtime_error("Scene file does not exist: " + path.string());
|
||||
if (std::filesystem::file_size(path) > 256 * 1024 * 1024)
|
||||
throw std::runtime_error("Scene file does not exist: " + faset::path_to_utf8(path));
|
||||
if (std::filesystem::file_size(faset::native_io_path(path)) > 256 * 1024 * 1024)
|
||||
throw std::runtime_error("Scene exceeds the 256 MiB reader limit");
|
||||
const auto bytes = faset::read_text(path);
|
||||
if (bytes.size() >= 8 && std::memcmp(bytes.data(), "FASETSCN", 8) == 0) {
|
||||
|
||||
+155
-74
@@ -6,6 +6,7 @@
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/render/render_graph.hpp>
|
||||
#include <faset/render/renderer.hpp>
|
||||
#include <fstream>
|
||||
@@ -88,7 +89,7 @@ struct Renderer::Impl {
|
||||
VkDescriptorPool descriptor_pool{};
|
||||
VkSampler shadow_sampler{}, color_sampler{};
|
||||
VkPipelineLayout pipeline_layout{};
|
||||
VkPipeline pipeline{}, ui_pipeline{}, shadow_pipeline{};
|
||||
VkPipeline pipeline{}, ui_pipeline{}, shadow_pipeline{}, sprite_pipeline{};
|
||||
struct GpuTexture {
|
||||
Image image;
|
||||
VkDescriptorSet descriptor{};
|
||||
@@ -150,6 +151,8 @@ struct Renderer::Impl {
|
||||
vkDestroyPipeline(device, ui_pipeline, nullptr);
|
||||
if (shadow_pipeline)
|
||||
vkDestroyPipeline(device, shadow_pipeline, nullptr);
|
||||
if (sprite_pipeline)
|
||||
vkDestroyPipeline(device, sprite_pipeline, nullptr);
|
||||
if (pipeline_layout)
|
||||
vkDestroyPipelineLayout(device, pipeline_layout, nullptr);
|
||||
if (descriptor_pool)
|
||||
@@ -207,7 +210,8 @@ struct Renderer::Impl {
|
||||
VkMemoryPropertyFlags properties, VkMemoryPropertyFlags preferred = 0) {
|
||||
Buffer b{};
|
||||
b.size = bytes;
|
||||
VkBufferCreateInfo info{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
|
||||
VkBufferCreateInfo info{};
|
||||
info.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
|
||||
info.size = bytes;
|
||||
info.usage = usage;
|
||||
info.sharingMode = VK_SHARING_MODE_EXCLUSIVE;
|
||||
@@ -215,7 +219,8 @@ struct Renderer::Impl {
|
||||
try {
|
||||
VkMemoryRequirements req{};
|
||||
vkGetBufferMemoryRequirements(device, b.handle, &req);
|
||||
VkMemoryAllocateInfo alloc{VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO};
|
||||
VkMemoryAllocateInfo alloc{};
|
||||
alloc.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
|
||||
alloc.allocationSize = req.size;
|
||||
alloc.memoryTypeIndex = memory_type(req.memoryTypeBits, properties, preferred);
|
||||
check(vkAllocateMemory(device, &alloc, nullptr, &b.memory), "Allocate buffer memory");
|
||||
@@ -230,7 +235,8 @@ struct Renderer::Impl {
|
||||
Image make_image(std::uint32_t w, std::uint32_t h, VkFormat format, VkImageUsageFlags usage,
|
||||
VkImageAspectFlags aspect) {
|
||||
Image image{};
|
||||
VkImageCreateInfo info{VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO};
|
||||
VkImageCreateInfo info{};
|
||||
info.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
|
||||
info.imageType = VK_IMAGE_TYPE_2D;
|
||||
info.format = format;
|
||||
info.extent = {w, h, 1};
|
||||
@@ -244,7 +250,8 @@ struct Renderer::Impl {
|
||||
try {
|
||||
VkMemoryRequirements req{};
|
||||
vkGetImageMemoryRequirements(device, image.handle, &req);
|
||||
VkMemoryAllocateInfo alloc{VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO};
|
||||
VkMemoryAllocateInfo alloc{};
|
||||
alloc.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
|
||||
alloc.allocationSize = req.size;
|
||||
alloc.memoryTypeIndex =
|
||||
memory_type(req.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
|
||||
@@ -252,7 +259,8 @@ struct Renderer::Impl {
|
||||
"Allocate image memory");
|
||||
image.allocation_size = req.size;
|
||||
check(vkBindImageMemory(device, image.handle, image.memory, 0), "Bind image memory");
|
||||
VkImageViewCreateInfo view{VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO};
|
||||
VkImageViewCreateInfo view{};
|
||||
view.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO;
|
||||
view.image = image.handle;
|
||||
view.viewType = VK_IMAGE_VIEW_TYPE_2D;
|
||||
view.format = format;
|
||||
@@ -267,7 +275,8 @@ struct Renderer::Impl {
|
||||
void transition(VkCommandBuffer cmd, VkImage image, VkImageLayout& before, VkImageLayout after,
|
||||
VkImageAspectFlags aspect) {
|
||||
// Conservative dependencies make the first single-queue backend auditable.
|
||||
VkImageMemoryBarrier2 barrier{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER_2};
|
||||
VkImageMemoryBarrier2 barrier{};
|
||||
barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER_2;
|
||||
barrier.srcStageMask = before == VK_IMAGE_LAYOUT_UNDEFINED
|
||||
? VK_PIPELINE_STAGE_2_NONE
|
||||
: VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT;
|
||||
@@ -280,7 +289,8 @@ struct Renderer::Impl {
|
||||
barrier.srcQueueFamilyIndex = barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
|
||||
barrier.image = image;
|
||||
barrier.subresourceRange = {aspect, 0, 1, 0, 1};
|
||||
VkDependencyInfo dependency{VK_STRUCTURE_TYPE_DEPENDENCY_INFO};
|
||||
VkDependencyInfo dependency{};
|
||||
dependency.sType = VK_STRUCTURE_TYPE_DEPENDENCY_INFO;
|
||||
dependency.imageMemoryBarrierCount = 1;
|
||||
dependency.pImageMemoryBarriers = &barrier;
|
||||
vkCmdPipelineBarrier2(cmd, &dependency);
|
||||
@@ -292,20 +302,23 @@ struct Renderer::Impl {
|
||||
}
|
||||
void begin() {
|
||||
check(vkResetCommandBuffer(command, 0), "Reset command buffer");
|
||||
VkCommandBufferBeginInfo info{VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO};
|
||||
VkCommandBufferBeginInfo info{};
|
||||
info.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
|
||||
info.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
|
||||
check(vkBeginCommandBuffer(command, &info), "Begin command buffer");
|
||||
}
|
||||
void submit(bool present = false) {
|
||||
check(vkEndCommandBuffer(command), "End command buffer");
|
||||
check(vkResetFences(device, 1, &fence), "Reset fence");
|
||||
VkCommandBufferSubmitInfo cmd{VK_STRUCTURE_TYPE_COMMAND_BUFFER_SUBMIT_INFO};
|
||||
VkCommandBufferSubmitInfo cmd{};
|
||||
cmd.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_SUBMIT_INFO;
|
||||
cmd.commandBuffer = command;
|
||||
VkSubmitInfo2 info{VK_STRUCTURE_TYPE_SUBMIT_INFO_2};
|
||||
VkSubmitInfo2 info{};
|
||||
info.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO_2;
|
||||
info.commandBufferInfoCount = 1;
|
||||
info.pCommandBufferInfos = &cmd;
|
||||
VkSemaphoreSubmitInfo wait{VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO},
|
||||
signal{VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO};
|
||||
VkSemaphoreSubmitInfo wait{}, signal{};
|
||||
wait.sType = signal.sType = VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO;
|
||||
if (present) {
|
||||
wait.semaphore = acquired;
|
||||
wait.stageMask = VK_PIPELINE_STAGE_2_TRANSFER_BIT;
|
||||
@@ -354,11 +367,12 @@ struct Renderer::Impl {
|
||||
std::cerr << "[Faset] Vulkan validation layer not installed; diagnostics disabled.\n";
|
||||
if (validation)
|
||||
extensions.push_back(VK_EXT_DEBUG_UTILS_EXTENSION_NAME);
|
||||
VkApplicationInfo app{VK_STRUCTURE_TYPE_APPLICATION_INFO};
|
||||
VkApplicationInfo app{};
|
||||
app.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO;
|
||||
app.pApplicationName = "Faset Engine";
|
||||
app.apiVersion = VK_API_VERSION_1_3;
|
||||
VkDebugUtilsMessengerCreateInfoEXT debug_info{
|
||||
VK_STRUCTURE_TYPE_DEBUG_UTILS_MESSENGER_CREATE_INFO_EXT};
|
||||
VkDebugUtilsMessengerCreateInfoEXT debug_info{};
|
||||
debug_info.sType = VK_STRUCTURE_TYPE_DEBUG_UTILS_MESSENGER_CREATE_INFO_EXT;
|
||||
debug_info.messageSeverity = VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT |
|
||||
VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT;
|
||||
debug_info.messageType = VK_DEBUG_UTILS_MESSAGE_TYPE_VALIDATION_BIT_EXT |
|
||||
@@ -367,7 +381,8 @@ struct Renderer::Impl {
|
||||
debug_info.pfnUserCallback = debug;
|
||||
debug_info.pUserData = this;
|
||||
const char* validation_name = "VK_LAYER_KHRONOS_validation";
|
||||
VkInstanceCreateInfo info{VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO};
|
||||
VkInstanceCreateInfo info{};
|
||||
info.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO;
|
||||
info.pApplicationInfo = &app;
|
||||
info.enabledExtensionCount = static_cast<std::uint32_t>(extensions.size());
|
||||
info.ppEnabledExtensionNames = extensions.data();
|
||||
@@ -395,9 +410,10 @@ struct Renderer::Impl {
|
||||
vkGetPhysicalDeviceProperties(gpu, &properties);
|
||||
if (properties.apiVersion < VK_API_VERSION_1_3)
|
||||
continue;
|
||||
VkPhysicalDeviceVulkan13Features f13{
|
||||
VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_3_FEATURES};
|
||||
VkPhysicalDeviceFeatures2 features{VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2};
|
||||
VkPhysicalDeviceVulkan13Features f13{};
|
||||
f13.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_3_FEATURES;
|
||||
VkPhysicalDeviceFeatures2 features{};
|
||||
features.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2;
|
||||
features.pNext = &f13;
|
||||
vkGetPhysicalDeviceFeatures2(gpu, &features);
|
||||
if (!f13.synchronization2 || !f13.dynamicRendering)
|
||||
@@ -436,14 +452,17 @@ struct Renderer::Impl {
|
||||
throw std::runtime_error("No Vulkan 1.3 device supports dynamic rendering, "
|
||||
"synchronization2 and required color/depth formats");
|
||||
float priority = 1;
|
||||
VkDeviceQueueCreateInfo qi{VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO};
|
||||
VkDeviceQueueCreateInfo qi{};
|
||||
qi.sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO;
|
||||
qi.queueFamilyIndex = queue_family;
|
||||
qi.queueCount = 1;
|
||||
qi.pQueuePriorities = &priority;
|
||||
VkPhysicalDeviceVulkan13Features f13{VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_3_FEATURES};
|
||||
VkPhysicalDeviceVulkan13Features f13{};
|
||||
f13.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_3_FEATURES;
|
||||
f13.synchronization2 = VK_TRUE;
|
||||
f13.dynamicRendering = VK_TRUE;
|
||||
VkDeviceCreateInfo di{VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO};
|
||||
VkDeviceCreateInfo di{};
|
||||
di.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO;
|
||||
di.pNext = &f13;
|
||||
di.queueCreateInfoCount = 1;
|
||||
di.pQueueCreateInfos = &qi;
|
||||
@@ -454,23 +473,28 @@ struct Renderer::Impl {
|
||||
}
|
||||
check(vkCreateDevice(physical, &di, nullptr, &device), "Create Vulkan device");
|
||||
vkGetDeviceQueue(device, queue_family, 0, &queue);
|
||||
VkCommandPoolCreateInfo pi{VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO};
|
||||
VkCommandPoolCreateInfo pi{};
|
||||
pi.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO;
|
||||
pi.queueFamilyIndex = queue_family;
|
||||
pi.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT;
|
||||
check(vkCreateCommandPool(device, &pi, nullptr, &pool), "Create command pool");
|
||||
VkCommandBufferAllocateInfo ai{VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO};
|
||||
VkCommandBufferAllocateInfo ai{};
|
||||
ai.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
|
||||
ai.commandPool = pool;
|
||||
ai.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
|
||||
ai.commandBufferCount = 1;
|
||||
check(vkAllocateCommandBuffers(device, &ai, &command), "Allocate command buffer");
|
||||
VkFenceCreateInfo fi{VK_STRUCTURE_TYPE_FENCE_CREATE_INFO};
|
||||
VkFenceCreateInfo fi{};
|
||||
fi.sType = VK_STRUCTURE_TYPE_FENCE_CREATE_INFO;
|
||||
fi.flags = VK_FENCE_CREATE_SIGNALED_BIT;
|
||||
check(vkCreateFence(device, &fi, nullptr, &fence), "Create frame fence");
|
||||
VkSemaphoreCreateInfo si{VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO};
|
||||
VkSemaphoreCreateInfo si{};
|
||||
si.sType = VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO;
|
||||
check(vkCreateSemaphore(device, &si, nullptr, &acquired), "Create acquire semaphore");
|
||||
check(vkCreateSemaphore(device, &si, nullptr, &present_ready), "Create present semaphore");
|
||||
if (timestamp_bits) {
|
||||
VkQueryPoolCreateInfo query{VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO};
|
||||
VkQueryPoolCreateInfo query{};
|
||||
query.sType = VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO;
|
||||
query.queryType = VK_QUERY_TYPE_TIMESTAMP;
|
||||
query.queryCount = 2;
|
||||
check(vkCreateQueryPool(device, &query, nullptr, ×tamp_pool),
|
||||
@@ -545,7 +569,8 @@ struct Renderer::Impl {
|
||||
count = caps.minImageCount + 1;
|
||||
if (caps.maxImageCount)
|
||||
count = std::min(count, caps.maxImageCount);
|
||||
VkSwapchainCreateInfoKHR info{VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR};
|
||||
VkSwapchainCreateInfoKHR info{};
|
||||
info.sType = VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR;
|
||||
info.surface = surface;
|
||||
info.minImageCount = count;
|
||||
info.imageFormat = chosen.format;
|
||||
@@ -595,21 +620,24 @@ struct Renderer::Impl {
|
||||
bindings[i].descriptorCount = 1;
|
||||
bindings[i].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT;
|
||||
}
|
||||
VkDescriptorSetLayoutCreateInfo li{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO};
|
||||
VkDescriptorSetLayoutCreateInfo li{};
|
||||
li.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO;
|
||||
li.bindingCount = 4;
|
||||
li.pBindings = bindings.data();
|
||||
check(vkCreateDescriptorSetLayout(device, &li, nullptr, &descriptor_layout),
|
||||
"Create descriptor layout");
|
||||
VkDescriptorPoolSize sizes[] = {{VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, 2048},
|
||||
{VK_DESCRIPTOR_TYPE_SAMPLER, 2048}};
|
||||
VkDescriptorPoolCreateInfo pi{VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO};
|
||||
VkDescriptorPoolCreateInfo pi{};
|
||||
pi.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO;
|
||||
pi.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT;
|
||||
pi.maxSets = 1024;
|
||||
pi.poolSizeCount = 2;
|
||||
pi.pPoolSizes = sizes;
|
||||
check(vkCreateDescriptorPool(device, &pi, nullptr, &descriptor_pool),
|
||||
"Create descriptor pool");
|
||||
VkSamplerCreateInfo si{VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO};
|
||||
VkSamplerCreateInfo si{};
|
||||
si.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO;
|
||||
si.magFilter = si.minFilter = VK_FILTER_NEAREST;
|
||||
si.mipmapMode = VK_SAMPLER_MIPMAP_MODE_NEAREST;
|
||||
si.addressModeU = si.addressModeV = si.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE;
|
||||
@@ -658,7 +686,8 @@ struct Renderer::Impl {
|
||||
VK_IMAGE_ASPECT_COLOR_BIT);
|
||||
submit();
|
||||
destroy(staging);
|
||||
VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO};
|
||||
VkDescriptorSetAllocateInfo ai{};
|
||||
ai.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO;
|
||||
ai.descriptorPool = descriptor_pool;
|
||||
ai.descriptorSetCount = 1;
|
||||
ai.pSetLayouts = &descriptor_layout;
|
||||
@@ -671,7 +700,7 @@ struct Renderer::Impl {
|
||||
{color_sampler, VK_NULL_HANDLE, VK_IMAGE_LAYOUT_UNDEFINED}};
|
||||
std::array<VkWriteDescriptorSet, 4> writes{};
|
||||
for (std::uint32_t i = 0; i < 4; ++i) {
|
||||
writes[i] = {VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};
|
||||
writes[i].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
|
||||
writes[i].dstSet = texture.descriptor;
|
||||
writes[i].dstBinding = i;
|
||||
writes[i].descriptorCount = 1;
|
||||
@@ -700,16 +729,17 @@ struct Renderer::Impl {
|
||||
std::vector<std::filesystem::path> roots;
|
||||
const char* base = SDL_GetBasePath();
|
||||
if (base)
|
||||
roots.emplace_back(std::filesystem::path(base) / "shaders");
|
||||
roots.emplace_back(faset::path_from_utf8(base) / "shaders");
|
||||
roots.emplace_back(std::filesystem::current_path() / "shaders");
|
||||
roots.emplace_back(FASET_SHADER_DIRECTORY);
|
||||
roots.emplace_back(faset::path_from_utf8(FASET_SHADER_DIRECTORY));
|
||||
for (const auto& root : roots)
|
||||
if (std::filesystem::is_regular_file(root / "vertexMain.spv"))
|
||||
if (std::filesystem::is_regular_file(faset::native_io_path(root / "vertexMain.spv")))
|
||||
return root;
|
||||
throw std::runtime_error("Compiled Slang shader bundle is missing");
|
||||
}
|
||||
VkShaderModule shader(const detail::ShaderCode& code) {
|
||||
VkShaderModuleCreateInfo ci{VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO};
|
||||
VkShaderModuleCreateInfo ci{};
|
||||
ci.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO;
|
||||
ci.codeSize = code.words.size() * sizeof(std::uint32_t);
|
||||
ci.pCode = code.words.data();
|
||||
VkShaderModule result{};
|
||||
@@ -724,7 +754,8 @@ struct Renderer::Impl {
|
||||
"Shader layout changed; the current pipeline was preserved");
|
||||
VkPushConstantRange push{VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 0,
|
||||
sizeof(Push)};
|
||||
VkPipelineLayoutCreateInfo li{VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO};
|
||||
VkPipelineLayoutCreateInfo li{};
|
||||
li.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
|
||||
li.setLayoutCount = 1;
|
||||
li.pSetLayouts = &descriptor_layout;
|
||||
li.pushConstantRangeCount = 1;
|
||||
@@ -736,14 +767,14 @@ struct Renderer::Impl {
|
||||
vertex = shader(shaders[0]);
|
||||
fragment = shader(shaders[1]);
|
||||
shadow_vertex = shader(shaders[2]);
|
||||
for (int mode = 0; mode < 3; ++mode) {
|
||||
bool shadow_pass = mode == 2, ui = mode == 1;
|
||||
for (int mode = 0; mode < 4; ++mode) {
|
||||
bool shadow_pass = mode == 2, ui = mode == 1, sprite = mode == 3;
|
||||
VkPipelineShaderStageCreateInfo stages[2]{};
|
||||
stages[0] = {VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO};
|
||||
stages[0].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
|
||||
stages[0].stage = VK_SHADER_STAGE_VERTEX_BIT;
|
||||
stages[0].module = shadow_pass ? shadow_vertex : vertex;
|
||||
stages[0].pName = "main";
|
||||
stages[1] = {VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO};
|
||||
stages[1].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
|
||||
stages[1].stage = VK_SHADER_STAGE_FRAGMENT_BIT;
|
||||
stages[1].module = fragment;
|
||||
stages[1].pName = "main";
|
||||
@@ -756,20 +787,20 @@ struct Renderer::Impl {
|
||||
{3, 0, VK_FORMAT_R32G32B32A32_SFLOAT, offsetof(GpuVertex, color)},
|
||||
{4, 0, VK_FORMAT_R32G32_SFLOAT, offsetof(GpuVertex, material)},
|
||||
{5, 0, VK_FORMAT_R32G32_SFLOAT, offsetof(GpuVertex, uv)}};
|
||||
VkPipelineVertexInputStateCreateInfo vi{
|
||||
VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO};
|
||||
VkPipelineVertexInputStateCreateInfo vi{};
|
||||
vi.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO;
|
||||
vi.vertexBindingDescriptionCount = 1;
|
||||
vi.pVertexBindingDescriptions = &binding;
|
||||
vi.vertexAttributeDescriptionCount = shadow_pass ? 1 : 6;
|
||||
vi.pVertexAttributeDescriptions = shadow_pass ? attrs + 1 : attrs;
|
||||
VkPipelineInputAssemblyStateCreateInfo ia{
|
||||
VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO};
|
||||
VkPipelineInputAssemblyStateCreateInfo ia{};
|
||||
ia.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO;
|
||||
ia.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST;
|
||||
VkPipelineViewportStateCreateInfo vp{
|
||||
VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO};
|
||||
VkPipelineViewportStateCreateInfo vp{};
|
||||
vp.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO;
|
||||
vp.viewportCount = vp.scissorCount = 1;
|
||||
VkPipelineRasterizationStateCreateInfo rs{
|
||||
VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO};
|
||||
VkPipelineRasterizationStateCreateInfo rs{};
|
||||
rs.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO;
|
||||
rs.polygonMode = VK_POLYGON_MODE_FILL;
|
||||
rs.cullMode = VK_CULL_MODE_NONE;
|
||||
rs.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE;
|
||||
@@ -777,13 +808,13 @@ struct Renderer::Impl {
|
||||
rs.depthBiasEnable = shadow_pass;
|
||||
rs.depthBiasConstantFactor = 1.25f;
|
||||
rs.depthBiasSlopeFactor = 1.75f;
|
||||
VkPipelineMultisampleStateCreateInfo ms{
|
||||
VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO};
|
||||
VkPipelineMultisampleStateCreateInfo ms{};
|
||||
ms.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO;
|
||||
ms.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT;
|
||||
VkPipelineDepthStencilStateCreateInfo ds{
|
||||
VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO};
|
||||
VkPipelineDepthStencilStateCreateInfo ds{};
|
||||
ds.sType = VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO;
|
||||
ds.depthTestEnable = !ui;
|
||||
ds.depthWriteEnable = !ui;
|
||||
ds.depthWriteEnable = !ui && !sprite;
|
||||
ds.depthCompareOp = VK_COMPARE_OP_LESS_OR_EQUAL;
|
||||
VkPipelineColorBlendAttachmentState blend{};
|
||||
blend.colorWriteMask = 15;
|
||||
@@ -794,22 +825,23 @@ struct Renderer::Impl {
|
||||
blend.srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE;
|
||||
blend.dstAlphaBlendFactor = VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA;
|
||||
blend.alphaBlendOp = VK_BLEND_OP_ADD;
|
||||
VkPipelineColorBlendStateCreateInfo cb{
|
||||
VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO};
|
||||
VkPipelineColorBlendStateCreateInfo cb{};
|
||||
cb.sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO;
|
||||
cb.attachmentCount = shadow_pass ? 0 : 1;
|
||||
cb.pAttachments = &blend;
|
||||
VkDynamicState states[] = {VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR};
|
||||
VkPipelineDynamicStateCreateInfo dynamic{
|
||||
VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO};
|
||||
VkPipelineDynamicStateCreateInfo dynamic{};
|
||||
dynamic.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO;
|
||||
dynamic.dynamicStateCount = 2;
|
||||
dynamic.pDynamicStates = states;
|
||||
VkFormat format = VK_FORMAT_R8G8B8A8_UNORM;
|
||||
VkPipelineRenderingCreateInfo rendering{
|
||||
VK_STRUCTURE_TYPE_PIPELINE_RENDERING_CREATE_INFO};
|
||||
VkPipelineRenderingCreateInfo rendering{};
|
||||
rendering.sType = VK_STRUCTURE_TYPE_PIPELINE_RENDERING_CREATE_INFO;
|
||||
rendering.colorAttachmentCount = shadow_pass ? 0 : 1;
|
||||
rendering.pColorAttachmentFormats = &format;
|
||||
rendering.depthAttachmentFormat = VK_FORMAT_D32_SFLOAT;
|
||||
VkGraphicsPipelineCreateInfo pi{VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO};
|
||||
VkGraphicsPipelineCreateInfo pi{};
|
||||
pi.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO;
|
||||
pi.pNext = &rendering;
|
||||
pi.stageCount = shadow_pass ? 1 : 2;
|
||||
pi.pStages = stages;
|
||||
@@ -822,7 +854,10 @@ struct Renderer::Impl {
|
||||
pi.pColorBlendState = &cb;
|
||||
pi.pDynamicState = &dynamic;
|
||||
pi.layout = pipeline_layout;
|
||||
auto* output = shadow_pass ? &shadow_pipeline : ui ? &ui_pipeline : &pipeline;
|
||||
auto* output = shadow_pass ? &shadow_pipeline
|
||||
: ui ? &ui_pipeline
|
||||
: sprite ? &sprite_pipeline
|
||||
: &pipeline;
|
||||
check(vkCreateGraphicsPipelines(device, VK_NULL_HANDLE, 1, &pi, nullptr, output),
|
||||
"Create graphics pipeline");
|
||||
}
|
||||
@@ -999,7 +1034,7 @@ struct Renderer::Impl {
|
||||
if (sprite.texture)
|
||||
upload_texture(sprite.texture);
|
||||
std::vector<GpuVertex> data;
|
||||
std::vector<Batch> scene_batches, shadow_batches, ui_batches;
|
||||
std::vector<Batch> scene_batches, shadow_batches, sprite_batches, ui_batches;
|
||||
for (const auto& item : snapshot.draws) {
|
||||
if (!item.mesh)
|
||||
continue;
|
||||
@@ -1031,7 +1066,29 @@ struct Renderer::Impl {
|
||||
else
|
||||
scene_batches.push_back(batch);
|
||||
}
|
||||
struct OrderedSprite {
|
||||
const Sprite* sprite;
|
||||
float depth;
|
||||
};
|
||||
std::vector<OrderedSprite> ordered_sprites;
|
||||
ordered_sprites.reserve(snapshot.sprites.size());
|
||||
for (const auto& sprite : snapshot.sprites) {
|
||||
const auto clip =
|
||||
point(snapshot.view_projection,
|
||||
{sprite.position[0], sprite.position[1], sprite.position[2], 1});
|
||||
const auto depth =
|
||||
clip[3] != 0 ? clip[2] / clip[3] : std::numeric_limits<float>::infinity();
|
||||
ordered_sprites.push_back(
|
||||
{&sprite, std::isfinite(depth) ? depth : std::numeric_limits<float>::infinity()});
|
||||
}
|
||||
std::stable_sort(ordered_sprites.begin(), ordered_sprites.end(),
|
||||
[](const auto& a, const auto& b) {
|
||||
if (a.sprite->layer != b.sprite->layer)
|
||||
return a.sprite->layer < b.sprite->layer;
|
||||
return a.depth > b.depth;
|
||||
});
|
||||
for (const auto& ordered : ordered_sprites) {
|
||||
const auto& sprite = *ordered.sprite;
|
||||
auto first = static_cast<std::uint32_t>(data.size());
|
||||
float c = std::cos(sprite.rotation), s = std::sin(sprite.rotation);
|
||||
for (auto i : {0, 1, 2, 0, 2, 3}) {
|
||||
@@ -1048,7 +1105,7 @@ struct Renderer::Impl {
|
||||
vertex.material[0] = sprite.texture && sprite.texture->srgb ? 1.f : 0.f;
|
||||
data.push_back(vertex);
|
||||
}
|
||||
scene_batches.push_back(
|
||||
sprite_batches.push_back(
|
||||
{first, 6, sprite.texture ? sprite.texture.get() : white.get()});
|
||||
}
|
||||
for (const auto& q : snapshot.ui_quads) {
|
||||
@@ -1131,13 +1188,15 @@ struct Renderer::Impl {
|
||||
graph.add("ShadowMap", {}, {"shadow"}, [&] {
|
||||
transition(command, shadow, VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL,
|
||||
VK_IMAGE_ASPECT_DEPTH_BIT);
|
||||
VkRenderingAttachmentInfo attachment{VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO};
|
||||
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{VK_STRUCTURE_TYPE_RENDERING_INFO};
|
||||
VkRenderingInfo rendering{};
|
||||
rendering.sType = VK_STRUCTURE_TYPE_RENDERING_INFO;
|
||||
rendering.renderArea = {{0, 0}, {shadow_size, shadow_size}};
|
||||
rendering.layerCount = 1;
|
||||
rendering.pDepthAttachment = &attachment;
|
||||
@@ -1160,20 +1219,23 @@ struct Renderer::Impl {
|
||||
VK_IMAGE_ASPECT_COLOR_BIT);
|
||||
transition(command, depth, VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL,
|
||||
VK_IMAGE_ASPECT_DEPTH_BIT);
|
||||
VkRenderingAttachmentInfo ca{VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO};
|
||||
VkRenderingAttachmentInfo ca{};
|
||||
ca.sType = VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO;
|
||||
ca.imageView = color.view;
|
||||
ca.imageLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
|
||||
ca.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
|
||||
ca.storeOp = VK_ATTACHMENT_STORE_OP_STORE;
|
||||
std::copy(snapshot.clear_color.begin(), snapshot.clear_color.end(),
|
||||
ca.clearValue.color.float32);
|
||||
VkRenderingAttachmentInfo da{VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO};
|
||||
VkRenderingAttachmentInfo da{};
|
||||
da.sType = VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO;
|
||||
da.imageView = depth.view;
|
||||
da.imageLayout = VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL;
|
||||
da.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
|
||||
da.storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;
|
||||
da.clearValue.depthStencil = {1, 0};
|
||||
VkRenderingInfo rendering{VK_STRUCTURE_TYPE_RENDERING_INFO};
|
||||
VkRenderingInfo rendering{};
|
||||
rendering.sType = VK_STRUCTURE_TYPE_RENDERING_INFO;
|
||||
rendering.renderArea = {{0, 0}, {width, height}};
|
||||
rendering.layerCount = 1;
|
||||
rendering.colorAttachmentCount = 1;
|
||||
@@ -1205,6 +1267,14 @@ struct Renderer::Impl {
|
||||
vkCmdDraw(command, batch.count, 1, batch.first, 0);
|
||||
++statistics.draw_calls;
|
||||
}
|
||||
vkCmdBindPipeline(command, VK_PIPELINE_BIND_POINT_GRAPHICS, sprite_pipeline);
|
||||
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);
|
||||
vkCmdDraw(command, batch.count, 1, batch.first, 0);
|
||||
++statistics.draw_calls;
|
||||
}
|
||||
set_viewport(width, height);
|
||||
vkCmdBindPipeline(command, VK_PIPELINE_BIND_POINT_GRAPHICS, ui_pipeline);
|
||||
vkCmdPushConstants(command, pipeline_layout,
|
||||
@@ -1262,7 +1332,8 @@ struct Renderer::Impl {
|
||||
statistics.gpu_ms = double(delta) * timestamp_period / 1000000.0;
|
||||
}
|
||||
if (swap_index) {
|
||||
VkPresentInfoKHR present{VK_STRUCTURE_TYPE_PRESENT_INFO_KHR};
|
||||
VkPresentInfoKHR present{};
|
||||
present.sType = VK_STRUCTURE_TYPE_PRESENT_INFO_KHR;
|
||||
present.waitSemaphoreCount = 1;
|
||||
present.pWaitSemaphores = &present_ready;
|
||||
present.swapchainCount = 1;
|
||||
@@ -1313,10 +1384,12 @@ bool Renderer::reload_shaders(std::string& error) {
|
||||
auto previous = r.pipeline;
|
||||
auto previous_ui = r.ui_pipeline;
|
||||
auto previous_shadow = r.shadow_pipeline;
|
||||
auto previous_sprite = r.sprite_pipeline;
|
||||
r.pipeline_layout = {};
|
||||
r.pipeline = {};
|
||||
r.ui_pipeline = {};
|
||||
r.shadow_pipeline = {};
|
||||
r.sprite_pipeline = {};
|
||||
try {
|
||||
r.make_pipelines();
|
||||
} catch (const std::exception& exception) {
|
||||
@@ -1326,18 +1399,22 @@ bool Renderer::reload_shaders(std::string& error) {
|
||||
vkDestroyPipeline(r.device, r.ui_pipeline, nullptr);
|
||||
if (r.shadow_pipeline)
|
||||
vkDestroyPipeline(r.device, r.shadow_pipeline, nullptr);
|
||||
if (r.sprite_pipeline)
|
||||
vkDestroyPipeline(r.device, r.sprite_pipeline, nullptr);
|
||||
if (r.pipeline_layout)
|
||||
vkDestroyPipelineLayout(r.device, r.pipeline_layout, nullptr);
|
||||
r.pipeline_layout = previous_layout;
|
||||
r.pipeline = previous;
|
||||
r.ui_pipeline = previous_ui;
|
||||
r.shadow_pipeline = previous_shadow;
|
||||
r.sprite_pipeline = previous_sprite;
|
||||
error = exception.what();
|
||||
return false;
|
||||
}
|
||||
vkDestroyPipeline(r.device, previous, nullptr);
|
||||
vkDestroyPipeline(r.device, previous_ui, nullptr);
|
||||
vkDestroyPipeline(r.device, previous_shadow, nullptr);
|
||||
vkDestroyPipeline(r.device, previous_sprite, nullptr);
|
||||
vkDestroyPipelineLayout(r.device, previous_layout, nullptr);
|
||||
error.clear();
|
||||
return true;
|
||||
@@ -1360,6 +1437,10 @@ std::uint32_t Renderer::width() const {
|
||||
std::uint32_t Renderer::height() const {
|
||||
return impl_->height;
|
||||
}
|
||||
float Renderer::display_scale() const {
|
||||
const float scale = impl_->window ? SDL_GetWindowDisplayScale(impl_->window) : 1.f;
|
||||
return std::isfinite(scale) && scale > 0.f ? scale : 1.f;
|
||||
}
|
||||
bool Renderer::should_close() const {
|
||||
return impl_->close;
|
||||
}
|
||||
@@ -1372,9 +1453,9 @@ std::vector<std::uint8_t> Renderer::pixels() const {
|
||||
void Renderer::capture(const std::filesystem::path& path) {
|
||||
if (impl_->last_pixels.empty())
|
||||
throw std::runtime_error("Cannot capture before a completed frame");
|
||||
std::ofstream out(path, std::ios::binary);
|
||||
std::ofstream out(faset::native_io_path(path), std::ios::binary);
|
||||
if (!out)
|
||||
throw std::runtime_error("Cannot write screenshot: " + path.string());
|
||||
throw std::runtime_error("Cannot write screenshot: " + faset::path_to_utf8(path));
|
||||
out << "P6\n" << width() << ' ' << height() << "\n255\n";
|
||||
for (std::size_t i = 0; i < impl_->last_pixels.size(); i += 4)
|
||||
out.write(reinterpret_cast<const char*>(impl_->last_pixels.data() + i), 3);
|
||||
|
||||
@@ -14,8 +14,10 @@ void require(bool value, const std::string& message) {
|
||||
throw std::runtime_error("Shader contract: " + message);
|
||||
}
|
||||
std::string read_bounded(const std::filesystem::path& path, std::uintmax_t maximum) {
|
||||
require(std::filesystem::is_regular_file(path), "missing " + path.string());
|
||||
require(std::filesystem::file_size(path) <= maximum, "oversized " + path.string());
|
||||
const auto native = faset::native_io_path(path);
|
||||
require(std::filesystem::is_regular_file(native), "missing " + faset::path_to_utf8(path));
|
||||
require(std::filesystem::file_size(native) <= maximum,
|
||||
"oversized " + faset::path_to_utf8(path));
|
||||
return faset::read_text(path);
|
||||
}
|
||||
void locations(const Json& fields, std::initializer_list<const char*> types, const char* label) {
|
||||
|
||||
+71
-2
@@ -5,12 +5,19 @@
|
||||
#include <deque>
|
||||
#include <entt/entt.hpp>
|
||||
#include <faset/runtime/Runtime.hpp>
|
||||
#include <faset/runtime/schema.hpp>
|
||||
#include <numbers>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace faset::runtime {
|
||||
bool is_builtin_component(std::string_view type) noexcept {
|
||||
constexpr std::array<std::string_view, 7> types{
|
||||
"faset.transform", "faset.sprite", "faset.mesh", "faset.camera",
|
||||
"faset.light", "faset.rigid_body_2d", "faset.rigid_body_3d"};
|
||||
return std::find(types.begin(), types.end(), type) != types.end();
|
||||
}
|
||||
namespace {
|
||||
using Json = nlohmann::json;
|
||||
std::atomic<std::uint64_t> nextSession{1};
|
||||
@@ -20,6 +27,20 @@ void require(bool condition, const std::string& message) {
|
||||
if (!condition)
|
||||
throw std::invalid_argument(message);
|
||||
}
|
||||
std::uint64_t schemaVersion(const Json& record, const std::string& context) {
|
||||
if (!record.contains("version"))
|
||||
return 1;
|
||||
const auto& value = record.at("version");
|
||||
if (value.is_number_unsigned()) {
|
||||
const auto version = value.get<std::uint64_t>();
|
||||
require(version > 0, context + ": version must be a positive integer");
|
||||
return version;
|
||||
}
|
||||
require(value.is_number_integer(), context + ": version must be a positive integer");
|
||||
const auto version = value.get<std::int64_t>();
|
||||
require(version > 0, context + ": version must be a positive integer");
|
||||
return static_cast<std::uint64_t>(version);
|
||||
}
|
||||
template <std::size_t N>
|
||||
std::array<float, N> vectorValue(const Json& object, const char* key,
|
||||
std::array<float, N> fallback) {
|
||||
@@ -111,11 +132,13 @@ void validateEntity(const Json& entity, int dimension) {
|
||||
require(component.contains("type") && component["type"].is_string() &&
|
||||
!component["type"].get<std::string>().empty(),
|
||||
"component requires type");
|
||||
require(component.value("version", 1) == 1, "unsupported component version");
|
||||
const auto type = component["type"].get<std::string>();
|
||||
const auto version = schemaVersion(component, "component " + type);
|
||||
require(!is_builtin_component(type) || version == 1,
|
||||
"unsupported builtin component version: " + type);
|
||||
require(component.contains("fields") && component["fields"].is_object(),
|
||||
"component requires fields");
|
||||
require(ids.insert(component["id"].get<std::string>()).second, "duplicate component id");
|
||||
const auto type = component["type"].get<std::string>();
|
||||
require(types.insert(type).second, "duplicate component type");
|
||||
const auto& f = component["fields"];
|
||||
if (type == "faset.transform")
|
||||
@@ -196,6 +219,52 @@ Transform interpolate(const Transform& a, const Transform& b, float alpha) {
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void validate_scene_schemas(const Json& scene, const Json& gameplay_schema) {
|
||||
require(gameplay_schema.is_array() ||
|
||||
(gameplay_schema.is_object() && gameplay_schema.contains("types") &&
|
||||
gameplay_schema.at("types").is_array()),
|
||||
"gameplay schema must be an array or a types manifest");
|
||||
const auto& schemas =
|
||||
gameplay_schema.is_array() ? gameplay_schema : gameplay_schema.at("types");
|
||||
std::unordered_map<std::string, std::uint64_t> available;
|
||||
for (const auto& schema : schemas) {
|
||||
require(schema.is_object() && schema.contains("id") && schema.at("id").is_string() &&
|
||||
!schema.at("id").get<std::string>().empty(),
|
||||
"gameplay schema requires a nonempty TypeId");
|
||||
const auto id = schema.at("id").get<std::string>();
|
||||
const auto version = schemaVersion(schema, "schema " + id);
|
||||
require(!is_builtin_component(id),
|
||||
"gameplay schema duplicates a builtin component TypeId: " + id);
|
||||
require(available.emplace(id, version).second, "duplicate gameplay schema TypeId: " + id);
|
||||
}
|
||||
require(scene.is_object() && scene.contains("entities") && scene.at("entities").is_array(),
|
||||
"scene entities must be an array");
|
||||
for (const auto& entity : scene.at("entities")) {
|
||||
require(entity.is_object() && entity.contains("components") &&
|
||||
entity.at("components").is_array(),
|
||||
"entity components must be an array");
|
||||
for (const auto& component : entity.at("components")) {
|
||||
require(component.is_object() && component.contains("type") &&
|
||||
component.at("type").is_string() &&
|
||||
!component.at("type").get<std::string>().empty(),
|
||||
"component requires a nonempty TypeId");
|
||||
const auto type = component.at("type").get<std::string>();
|
||||
const auto version = schemaVersion(component, "component " + type);
|
||||
if (is_builtin_component(type)) {
|
||||
require(version == 1, "unsupported builtin component version: " + type);
|
||||
continue;
|
||||
}
|
||||
const auto found = available.find(type);
|
||||
require(found != available.end(),
|
||||
"component schema is absent from linked gameplay module: " + type);
|
||||
require(found->second == version, "component schema version mismatch: " + type +
|
||||
" (scene " + std::to_string(version) +
|
||||
", linked gameplay " +
|
||||
std::to_string(found->second) + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Runtime::Impl {
|
||||
struct Data {
|
||||
Json document;
|
||||
|
||||
+8
-2
@@ -1,3 +1,4 @@
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/ui/ui.hpp>
|
||||
#include <ft2build.h>
|
||||
#include FT_FREETYPE_H
|
||||
@@ -6,6 +7,7 @@
|
||||
#include <fstream>
|
||||
#include <hb-ft.h>
|
||||
#include <hb.h>
|
||||
#include <limits>
|
||||
#include <map>
|
||||
#include <stdexcept>
|
||||
|
||||
@@ -41,12 +43,16 @@ struct FontAtlas::Impl {
|
||||
std::map<std::pair<unsigned, unsigned>, Glyph> glyphs;
|
||||
unsigned x = 2, y = 2, row_height = 0, size = 0;
|
||||
explicit Impl(const std::filesystem::path& path) {
|
||||
std::ifstream in(path, std::ios::binary | std::ios::ate);
|
||||
// FreeType's Windows path backend uses CreateFileA. Read a native path
|
||||
// ourselves and retain its bytes until hb_font/FT_Face are destroyed.
|
||||
std::ifstream in(native_io_path(path), std::ios::binary | std::ios::ate);
|
||||
if (!in)
|
||||
throw std::runtime_error("Cannot open UI font: " + path.string());
|
||||
throw std::runtime_error("Cannot open UI font: " + path_to_utf8(path));
|
||||
auto length = in.tellg();
|
||||
if (length <= 0)
|
||||
throw std::runtime_error("Empty UI font");
|
||||
if (length > std::numeric_limits<FT_Long>::max())
|
||||
throw std::runtime_error("UI font exceeds FreeType's supported buffer size");
|
||||
font_bytes.resize(static_cast<std::size_t>(length));
|
||||
in.seekg(0);
|
||||
in.read(reinterpret_cast<char*>(font_bytes.data()), length);
|
||||
|
||||
+18
-1
@@ -739,9 +739,26 @@ void Context::apply_layout(const Json& document) {
|
||||
apply(root(), definition);
|
||||
}
|
||||
void Context::layout(float width, float height, float scale) {
|
||||
if (!std::isfinite(width) || !std::isfinite(height) || !std::isfinite(scale) || scale <= 0)
|
||||
throw std::invalid_argument("UI dimensions and display scale must be finite and valid");
|
||||
scale = std::clamp(scale, .5f, 4.f);
|
||||
if (impl_->scale != scale) {
|
||||
// Pointer captures use the old drawable coordinate system. Keep edits,
|
||||
// but cancel a drag rather than committing a jump after a monitor change.
|
||||
impl_->cancel_capture();
|
||||
const auto ratio = scale / impl_->scale;
|
||||
std::function<void(Widget&)> rescale = [&](Widget& widget) {
|
||||
widget.scroll_y *= ratio;
|
||||
for (auto& child : widget.children)
|
||||
rescale(*child);
|
||||
};
|
||||
rescale(root());
|
||||
for (auto& [id, edit] : impl_->edits)
|
||||
edit.scroll *= ratio;
|
||||
}
|
||||
impl_->width = std::max(0.f, width);
|
||||
impl_->height = std::max(0.f, height);
|
||||
impl_->scale = std::clamp(scale, .5f, 4.f);
|
||||
impl_->scale = scale;
|
||||
std::set<std::string> ids;
|
||||
std::function<void(Widget&)> check = [&](Widget& w) {
|
||||
if (w.id.empty() || !ids.insert(w.id).second)
|
||||
|
||||
Reference in New Issue
Block a user