Checkpoint 4: harden native paths, authoring workflows and Player lifecycle

This commit is contained in:
Emil
2026-09-18 05:14:50 +03:00
parent 5ac4db438d
commit 45bc352d46
88 changed files with 12606 additions and 504 deletions
+4 -4
View File
@@ -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");
+48 -28
View File
@@ -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
+5 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+44 -37
View File
@@ -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);
+5 -4
View File
@@ -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
View File
@@ -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();
}
};
+6 -4
View File
@@ -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
View File
@@ -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}};
});
+1
View File
@@ -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]);
+2 -2
View File
@@ -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
View File
@@ -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, &timestamp_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);
+4 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)