Checkpoint 3: deliver playable samples and complete native authoring workflows
This commit is contained in:
@@ -54,6 +54,7 @@ int run_editor_ui(Session& session, bool enable_mcp, std::uint64_t max_frames,
|
||||
const auto font = session.config().engine_root / "assets/fonts/NotoSans.ttf";
|
||||
const auto theme = session.config().engine_root / "assets/ui/dark.json";
|
||||
EditorUI ui(session, renderer, font, theme);
|
||||
ui.set_project_switch_enabled(!enable_mcp);
|
||||
McpServer server(session.commands());
|
||||
StdioTransport transport;
|
||||
session.commands().add(
|
||||
@@ -75,6 +76,8 @@ int run_editor_ui(Session& session, bool enable_mcp, std::uint64_t max_frames,
|
||||
auto encoded = png(renderer, region);
|
||||
const auto relative =
|
||||
arguments.value("path", std::string(".faset/screenshots/editor.png"));
|
||||
require(std::filesystem::path(relative).extension() == ".png", "capture.path",
|
||||
"Editor screenshots must use a .png path");
|
||||
atomic_write(
|
||||
project_path(session.config().project_root, relative),
|
||||
std::string_view(reinterpret_cast<const char*>(encoded.data()), encoded.size()));
|
||||
@@ -100,6 +103,8 @@ int run_editor_ui(Session& session, bool enable_mcp, std::uint64_t max_frames,
|
||||
session.poll();
|
||||
ui.frame(renderer.poll_events());
|
||||
renderer.render(ui.snapshot());
|
||||
if (ui.project_switch_requested())
|
||||
return 3; // Application-level request: destroy this Session before opening another.
|
||||
++frame;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||
}
|
||||
|
||||
+90
-57
@@ -7,6 +7,7 @@
|
||||
#include <thread>
|
||||
#ifdef FASET_HAS_EDITOR_UI
|
||||
#include <faset/editor/editor_ui.hpp>
|
||||
#include <faset/editor/project_launcher.hpp>
|
||||
namespace faset::editor {
|
||||
int run_editor_ui(Session&, bool, std::uint64_t, const std::filesystem::path&);
|
||||
}
|
||||
@@ -38,6 +39,7 @@ std::filesystem::path executable_directory(const char* argument) {
|
||||
void help() {
|
||||
std::cout
|
||||
<< "Faset Editor\n"
|
||||
" faset_editor Open the project launcher\n"
|
||||
" faset_editor --project PATH [--new NAME --dimension 2|3] [--scene RELATIVE_PATH]\n"
|
||||
" faset_editor --project PATH --mcp [--gui]\n"
|
||||
" faset_editor --project PATH --command JSON [--wait]\n"
|
||||
@@ -97,71 +99,102 @@ int main(int argc, char** argv) {
|
||||
else
|
||||
throw Error("cli.option", "Unknown option: " + arg);
|
||||
}
|
||||
require(!project.empty(), "cli.project", "Use --project PATH to select a project");
|
||||
require(!(mcp && !command.empty()), "cli.mode", "Choose MCP or a single command");
|
||||
if (mcp && !explicit_gui)
|
||||
gui = false;
|
||||
Session session({std::filesystem::absolute(project), std::filesystem::absolute(engine),
|
||||
executable_directory(argv[0])});
|
||||
if (!new_name.empty())
|
||||
session.scaffold(new_name, dimension);
|
||||
const auto settings = session.project();
|
||||
if (scene.empty())
|
||||
scene = settings.value("start_scene", std::string());
|
||||
if (!scene.empty() &&
|
||||
std::filesystem::exists(project_path(session.config().project_root, scene)))
|
||||
session.authoring().open(scene);
|
||||
if (!command.empty()) {
|
||||
const auto request = Json::parse(command);
|
||||
auto result = session.commands().call(request.at("name"),
|
||||
request.value("arguments", Json::object()));
|
||||
if (wait && result.contains("job")) {
|
||||
const auto id = result.at("job");
|
||||
const auto started = std::chrono::steady_clock::now();
|
||||
for (;;) {
|
||||
session.poll();
|
||||
result = session.commands().call("faset_job", {{"id", id}});
|
||||
const auto state = result.value("state", std::string());
|
||||
if (state == "succeeded" || state == "failed" || state == "cancelled" ||
|
||||
state == "conflict")
|
||||
break;
|
||||
require(std::chrono::steady_clock::now() - started < std::chrono::minutes(30),
|
||||
"job.timeout", "Command wait exceeded 30 minutes");
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
}
|
||||
}
|
||||
std::cout << result.dump(2) << '\n';
|
||||
const auto state = result.value("state", std::string());
|
||||
return state == "failed" || state == "cancelled" || state == "conflict" ? 1 : 0;
|
||||
}
|
||||
if (gui) {
|
||||
require(!project.empty() || (gui && !mcp && new_name.empty()), "cli.project",
|
||||
"Use --project PATH for command, MCP, or --new modes");
|
||||
auto previous_project = project;
|
||||
for (;;) {
|
||||
if (project.empty()) {
|
||||
#ifdef FASET_HAS_EDITOR_UI
|
||||
return run_editor_ui(session, mcp, frames, capture);
|
||||
const auto selection =
|
||||
run_project_launcher(engine, previous_project, frames, capture);
|
||||
if (!selection)
|
||||
return 0;
|
||||
project = selection->path;
|
||||
new_name = selection->create ? selection->name : std::string();
|
||||
dimension = selection->dimension;
|
||||
#else
|
||||
throw Error("editor.gui_unavailable",
|
||||
"This build has no graphical editor; use --mcp or --command, or build with "
|
||||
"FASET_BUILD_EDITOR=ON");
|
||||
throw Error("editor.gui_unavailable",
|
||||
"This build has no graphical project launcher; use --project PATH");
|
||||
#endif
|
||||
}
|
||||
require(mcp, "cli.mode", "Headless mode requires --mcp or --command");
|
||||
std::signal(SIGINT, interrupt);
|
||||
std::signal(SIGTERM, interrupt);
|
||||
McpServer server(session.commands());
|
||||
StdioTransport transport;
|
||||
while (!interrupted && !transport.closed()) {
|
||||
for (const auto& line : transport.poll()) {
|
||||
try {
|
||||
const auto reply = server.handle(Json::parse(line));
|
||||
if (reply)
|
||||
transport.send(*reply);
|
||||
} catch (const Json::exception&) {
|
||||
transport.send(server.parse_error());
|
||||
}
|
||||
}
|
||||
session.poll();
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
Session session({std::filesystem::absolute(project), std::filesystem::absolute(engine),
|
||||
executable_directory(argv[0])});
|
||||
if (!new_name.empty())
|
||||
session.scaffold(new_name, dimension);
|
||||
const auto settings = session.project();
|
||||
#ifdef FASET_HAS_EDITOR_UI
|
||||
if (gui && std::filesystem::exists(project / "project.faset.json"))
|
||||
remember_project(project);
|
||||
#endif
|
||||
if (scene.empty())
|
||||
scene = settings.value("start_scene", std::string());
|
||||
if (!scene.empty() &&
|
||||
std::filesystem::exists(project_path(session.config().project_root, scene)))
|
||||
session.authoring().open(scene);
|
||||
if (!command.empty()) {
|
||||
const auto request = Json::parse(command);
|
||||
auto result = session.commands().call(request.at("name"),
|
||||
request.value("arguments", Json::object()));
|
||||
if (wait && result.contains("job")) {
|
||||
const auto id = result.at("job");
|
||||
const auto started = std::chrono::steady_clock::now();
|
||||
for (;;) {
|
||||
session.poll();
|
||||
result = session.commands().call("faset_job", {{"id", id}});
|
||||
const auto state = result.value("state", std::string());
|
||||
if (state == "succeeded" || state == "failed" || state == "cancelled" ||
|
||||
state == "conflict")
|
||||
break;
|
||||
require(std::chrono::steady_clock::now() - started <
|
||||
std::chrono::minutes(30),
|
||||
"job.timeout", "Command wait exceeded 30 minutes");
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
}
|
||||
}
|
||||
std::cout << result.dump(2) << '\n';
|
||||
const auto state = result.value("state", std::string());
|
||||
return state == "failed" || state == "cancelled" || state == "conflict" ? 1 : 0;
|
||||
}
|
||||
if (gui) {
|
||||
#ifdef FASET_HAS_EDITOR_UI
|
||||
const int result = run_editor_ui(session, mcp, frames, capture);
|
||||
if (result != 3)
|
||||
return result;
|
||||
previous_project = project;
|
||||
project.clear();
|
||||
scene.clear();
|
||||
new_name.clear();
|
||||
continue;
|
||||
#else
|
||||
throw Error(
|
||||
"editor.gui_unavailable",
|
||||
"This build has no graphical editor; use --mcp or --command, or build with "
|
||||
"FASET_BUILD_EDITOR=ON");
|
||||
#endif
|
||||
}
|
||||
require(mcp, "cli.mode", "Headless mode requires --mcp or --command");
|
||||
std::signal(SIGINT, interrupt);
|
||||
std::signal(SIGTERM, interrupt);
|
||||
McpServer server(session.commands());
|
||||
StdioTransport transport;
|
||||
while (!interrupted && !transport.closed()) {
|
||||
for (const auto& line : transport.poll()) {
|
||||
try {
|
||||
const auto reply = server.handle(Json::parse(line));
|
||||
if (reply)
|
||||
transport.send(*reply);
|
||||
} catch (const Json::exception&) {
|
||||
transport.send(server.parse_error());
|
||||
}
|
||||
}
|
||||
session.poll();
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
return 0;
|
||||
} catch (const Error& error) {
|
||||
std::cerr << error.json().dump() << '\n';
|
||||
return 1;
|
||||
|
||||
+188
-22
@@ -2,6 +2,7 @@
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/player/SceneView.hpp>
|
||||
#include <faset/runtime/Runtime.hpp>
|
||||
@@ -9,12 +10,103 @@
|
||||
#include <iostream>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
#include <vector>
|
||||
#if defined(_WIN32)
|
||||
#define NOMINMAX
|
||||
#include <windows.h>
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
using Clock = std::chrono::steady_clock;
|
||||
using Json = nlohmann::json;
|
||||
double milliseconds(Clock::time_point begin, Clock::time_point end) {
|
||||
return std::chrono::duration<double, std::milli>(end - begin).count();
|
||||
}
|
||||
struct ProfileSample {
|
||||
double wall{}, simulation{}, snapshot{}, render{}, rendererCpu{}, gpu{}, readbackCpu{};
|
||||
faset::runtime::FrameStats runtime;
|
||||
std::uint32_t draws{}, vertices{};
|
||||
std::uint64_t gpuAllocatedBytes{};
|
||||
std::uint32_t textureCount{};
|
||||
bool physicsDebug{};
|
||||
};
|
||||
Json distribution(std::vector<double> values) {
|
||||
if (values.empty())
|
||||
return nullptr;
|
||||
std::sort(values.begin(), values.end());
|
||||
auto percentile = [&](double fraction) {
|
||||
return values[static_cast<std::size_t>(std::ceil(fraction * values.size())) - 1];
|
||||
};
|
||||
return {{"samples", values.size()},
|
||||
{"min", values.front()},
|
||||
{"p50", percentile(.5)},
|
||||
{"p95", percentile(.95)},
|
||||
{"max", values.back()}};
|
||||
}
|
||||
Json profileFrames(const std::vector<ProfileSample>& samples) {
|
||||
Json frames = Json::array();
|
||||
std::vector<double> wall, simulation, snapshot, render, rendererCpu, gpu, readbackCpu;
|
||||
for (const auto& sample : samples) {
|
||||
wall.push_back(sample.wall);
|
||||
simulation.push_back(sample.simulation);
|
||||
snapshot.push_back(sample.snapshot);
|
||||
render.push_back(sample.render);
|
||||
rendererCpu.push_back(sample.rendererCpu);
|
||||
readbackCpu.push_back(sample.readbackCpu);
|
||||
// The backend reports zero if timestamp queries are unavailable. Do not
|
||||
// present that sentinel as a measured zero-cost GPU frame.
|
||||
const bool gpuMeasured = std::isfinite(sample.gpu) && sample.gpu > 0;
|
||||
if (gpuMeasured)
|
||||
gpu.push_back(sample.gpu);
|
||||
frames.push_back({{"frame", frames.size() + 1},
|
||||
{"wall_ms", sample.wall},
|
||||
{"simulation_ms", sample.simulation},
|
||||
{"snapshot_ms", sample.snapshot},
|
||||
{"render_call_ms", sample.render},
|
||||
{"renderer_cpu_ms", sample.rendererCpu},
|
||||
{"renderer_readback_cpu_ms", sample.readbackCpu},
|
||||
{"gpu_ms", gpuMeasured ? Json(sample.gpu) : Json(nullptr)},
|
||||
{"fixed_ticks", sample.runtime.fixedTicks},
|
||||
{"tick", sample.runtime.tick},
|
||||
{"dropped_simulation_seconds", sample.runtime.droppedTime},
|
||||
{"interpolation_alpha", sample.runtime.interpolationAlpha},
|
||||
{"draw_calls", sample.draws},
|
||||
{"vertices", sample.vertices},
|
||||
{"gpu_allocated_bytes", sample.gpuAllocatedBytes},
|
||||
{"texture_count", sample.textureCount},
|
||||
{"physics_debug", sample.physicsDebug}});
|
||||
}
|
||||
return {{"samples", std::move(frames)},
|
||||
{"summary_ms",
|
||||
{{"wall", distribution(std::move(wall))},
|
||||
{"simulation", distribution(std::move(simulation))},
|
||||
{"snapshot", distribution(std::move(snapshot))},
|
||||
{"render_call", distribution(std::move(render))},
|
||||
{"renderer_cpu", distribution(std::move(rendererCpu))},
|
||||
{"renderer_readback_cpu", distribution(std::move(readbackCpu))},
|
||||
{"gpu", distribution(std::move(gpu))}}}};
|
||||
}
|
||||
Json physicsScene(const faset::runtime::Runtime& world, const Json& presentation) {
|
||||
const int dimension = presentation.at("dimension");
|
||||
const std::string bodyName = dimension == 2 ? "rigid_body_2d" : "rigid_body_3d";
|
||||
Json result{{"dimension", dimension}, {"entities", Json::array()}};
|
||||
for (const auto& entity : presentation.at("entities")) {
|
||||
const auto handle = world.find(entity.at("id").get<std::string>());
|
||||
Json body;
|
||||
try {
|
||||
body = world.fields(handle, "faset." + bodyName);
|
||||
} catch (const std::invalid_argument&) {
|
||||
continue; // Render-only entities do not own a physics component.
|
||||
}
|
||||
const auto pose = world.transform(handle); // Current physics pose, not interpolation.
|
||||
result["entities"].push_back(
|
||||
{{"parent", nullptr},
|
||||
{"transform",
|
||||
{{"position", pose.position}, {"rotation", pose.rotation}, {"scale", pose.scale}}},
|
||||
{bodyName, std::move(body)}});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
std::filesystem::path executableDirectory(const char* argument) {
|
||||
#if defined(_WIN32)
|
||||
std::wstring path(32768, L'\0');
|
||||
@@ -70,31 +162,29 @@ faset::runtime::RuntimeConfig simulationConfig(const nlohmann::json& scene) {
|
||||
return config;
|
||||
}
|
||||
void validatePackagedShaders(const std::filesystem::path& directory) {
|
||||
for (const auto* name : {"vertexMain.spv", "fragmentMain.spv", "shadowMain.spv"}) {
|
||||
const auto path = directory / "shaders" / name;
|
||||
if (!std::filesystem::is_regular_file(path))
|
||||
throw std::runtime_error("Packaged shader is missing: " + path.string());
|
||||
const auto bytes = faset::read_text(path);
|
||||
if (bytes.size() < 20 || bytes.size() % 4 != 0 ||
|
||||
static_cast<unsigned char>(bytes[0]) != 0x03 ||
|
||||
static_cast<unsigned char>(bytes[1]) != 0x02 ||
|
||||
static_cast<unsigned char>(bytes[2]) != 0x23 ||
|
||||
static_cast<unsigned char>(bytes[3]) != 0x07)
|
||||
throw std::runtime_error("Packaged shader is not a SPIR-V module: " + path.string());
|
||||
}
|
||||
const auto shaders = directory / "shaders";
|
||||
for (const auto* entry : {"vertexMain", "fragmentMain", "shadowMain"})
|
||||
for (const auto* extension : {".spv", ".reflection.json"}) {
|
||||
const auto path = shaders / (std::string(entry) + extension);
|
||||
if (!std::filesystem::is_regular_file(path))
|
||||
throw std::runtime_error("Packaged shader file is missing: " + path.string());
|
||||
}
|
||||
faset::render::validate_shader_bundle(shaders);
|
||||
}
|
||||
} // namespace
|
||||
int main(int argc, char** argv) {
|
||||
const auto started = Clock::now();
|
||||
try {
|
||||
std::filesystem::path scenePath, assetsPath, capturePath, controlPath;
|
||||
bool headless = false, validateOnly = false;
|
||||
std::filesystem::path scenePath, assetsPath, capturePath, controlPath, profilePath;
|
||||
bool headless = false, validateOnly = false, debugPhysics = false;
|
||||
std::uint64_t maximumFrames = 0;
|
||||
std::set<std::string> options;
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
const std::string arg = argv[i];
|
||||
if (arg == "--help") {
|
||||
std::cout << "faset_player [--scene PATH] [--assets CACHE] [--frames N] "
|
||||
"[--headless] [--capture PATH.ppm] [--validate] [--control PATH]\n"
|
||||
"[--headless] [--capture PATH.ppm] [--validate] [--control PATH] "
|
||||
"[--profile PATH.json] [--debug-physics]\n"
|
||||
"No --scene: open scene.fscene beside the executable. CACHE contains "
|
||||
"assets/<id>/.\n"
|
||||
"Headless uses offscreen Vulkan; --frames uses the configured fixed "
|
||||
@@ -103,8 +193,10 @@ int main(int argc, char** argv) {
|
||||
"or Vulkan initialization.\n"
|
||||
"--control is an optional editor mailbox for pause/resume/step/stop, "
|
||||
"without world queries.\n"
|
||||
"--profile requires explicit --frames 1..100000; measured durations "
|
||||
"include the first frame and renderer GPU waits/readback.\n"
|
||||
"Keys: A/D horizontal, W/S vertical, Space jump, E interact, P pause, "
|
||||
"N single-step, Escape quit.\n";
|
||||
"N single-step, F3 physics boxes, Escape quit.\n";
|
||||
return 0;
|
||||
}
|
||||
if (!options.insert(arg).second)
|
||||
@@ -122,15 +214,24 @@ int main(int argc, char** argv) {
|
||||
capturePath = value();
|
||||
else if (arg == "--control")
|
||||
controlPath = value();
|
||||
else if (arg == "--profile")
|
||||
profilePath = value();
|
||||
else if (arg == "--frames")
|
||||
maximumFrames = count(value());
|
||||
else if (arg == "--headless")
|
||||
headless = true;
|
||||
else if (arg == "--validate")
|
||||
validateOnly = true;
|
||||
else if (arg == "--debug-physics")
|
||||
debugPhysics = true;
|
||||
else
|
||||
throw std::invalid_argument("Unknown option: " + arg);
|
||||
}
|
||||
if (options.contains("--profile") &&
|
||||
(profilePath.empty() || !options.contains("--frames") || maximumFrames > 100000 ||
|
||||
validateOnly))
|
||||
throw std::invalid_argument("--profile requires an output path and explicit --frames "
|
||||
"1..100000, without --validate");
|
||||
if (scenePath.empty())
|
||||
scenePath = executableDirectory(argv[0]) / "scene.fscene";
|
||||
scenePath = std::filesystem::absolute(scenePath).lexically_normal();
|
||||
@@ -141,8 +242,10 @@ int main(int argc, char** argv) {
|
||||
assetsPath.string());
|
||||
if (headless && maximumFrames == 0)
|
||||
maximumFrames = 1;
|
||||
const auto sceneReadStarted = Clock::now();
|
||||
const auto document = faset::player::readScene(scenePath);
|
||||
const auto config = simulationConfig(document);
|
||||
const auto sceneReadFinished = Clock::now();
|
||||
const auto executableRoot = executableDirectory(argv[0]);
|
||||
if (scenePath.extension() == ".fscene" &&
|
||||
std::filesystem::equivalent(scenePath.parent_path(), executableRoot))
|
||||
@@ -165,12 +268,19 @@ int main(int argc, char** argv) {
|
||||
<< '\n';
|
||||
return 0;
|
||||
}
|
||||
const auto worldStarted = Clock::now();
|
||||
faset::runtime::Runtime world(config);
|
||||
faset::gameplay::registerGameplay(world);
|
||||
world.load(document);
|
||||
faset::player::SceneView view(assetsPath);
|
||||
const auto rendererStarted = Clock::now();
|
||||
faset::render::Renderer renderer(
|
||||
{1280, 720, document.value("name", std::string("Faset Player")), headless, true});
|
||||
const auto rendererReady = Clock::now();
|
||||
std::vector<ProfileSample> profile;
|
||||
if (!profilePath.empty())
|
||||
profile.reserve(static_cast<std::size_t>(maximumFrames));
|
||||
Json firstFrameMs = nullptr;
|
||||
std::set<std::string> held;
|
||||
bool stop = false;
|
||||
std::uint64_t frames = 0;
|
||||
@@ -181,6 +291,7 @@ int main(int argc, char** argv) {
|
||||
auto previous = std::chrono::steady_clock::now();
|
||||
while (!stop && !renderer.should_close() &&
|
||||
(maximumFrames == 0 || frames < maximumFrames)) {
|
||||
const auto frameStarted = Clock::now();
|
||||
faset::runtime::InputState input;
|
||||
bool singleStep = false;
|
||||
if (!controlPath.empty() && std::filesystem::is_regular_file(controlPath)) {
|
||||
@@ -244,6 +355,8 @@ int main(int argc, char** argv) {
|
||||
world.setPaused(!world.paused());
|
||||
if (key == "N")
|
||||
singleStep = true;
|
||||
if (key == "F3")
|
||||
debugPhysics = !debugPhysics;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -258,12 +371,16 @@ int main(int argc, char** argv) {
|
||||
? config.fixedDelta
|
||||
: std::chrono::duration<double>(now - previous).count();
|
||||
previous = now;
|
||||
if (singleStep && world.paused())
|
||||
world.singleStep(input);
|
||||
else
|
||||
world.advance(elapsed, input);
|
||||
auto snapshot = view.build(world.snapshotJson(), static_cast<float>(renderer.width()) /
|
||||
std::max(1u, renderer.height()));
|
||||
const auto simulationStarted = Clock::now();
|
||||
const auto runtimeStats = singleStep && world.paused() ? world.singleStep(input)
|
||||
: world.advance(elapsed, input);
|
||||
const auto simulationFinished = Clock::now();
|
||||
const auto presentation = world.snapshotJson();
|
||||
auto snapshot = view.build(presentation, static_cast<float>(renderer.width()) /
|
||||
std::max(1u, renderer.height()));
|
||||
if (debugPhysics)
|
||||
view.appendPhysicsDebug(snapshot, physicsScene(world, presentation));
|
||||
const auto snapshotFinished = Clock::now();
|
||||
for (const auto& diagnostic : view.diagnostics()) {
|
||||
if (diagnostic.starts_with("error:"))
|
||||
throw std::runtime_error(diagnostic);
|
||||
@@ -272,7 +389,21 @@ int main(int argc, char** argv) {
|
||||
}
|
||||
while (logCursor < world.diagnostics().size())
|
||||
std::cerr << world.diagnostics()[logCursor++] << '\n';
|
||||
const auto renderStarted = Clock::now();
|
||||
renderer.render(snapshot);
|
||||
const auto frameFinished = Clock::now();
|
||||
if (frames == 0)
|
||||
firstFrameMs = milliseconds(started, frameFinished);
|
||||
if (!profilePath.empty()) {
|
||||
const auto measured = renderer.stats();
|
||||
profile.push_back(
|
||||
{milliseconds(frameStarted, frameFinished),
|
||||
milliseconds(simulationStarted, simulationFinished),
|
||||
milliseconds(simulationFinished, snapshotFinished),
|
||||
milliseconds(renderStarted, frameFinished), measured.cpu_ms, measured.gpu_ms,
|
||||
measured.readback_cpu_ms, runtimeStats, measured.draw_calls, measured.vertices,
|
||||
measured.gpu_allocated_bytes, measured.texture_count, debugPhysics});
|
||||
}
|
||||
++frames;
|
||||
}
|
||||
if (!capturePath.empty()) {
|
||||
@@ -281,6 +412,41 @@ int main(int argc, char** argv) {
|
||||
renderer.capture(capturePath);
|
||||
}
|
||||
const auto stats = renderer.stats();
|
||||
if (!profilePath.empty()) {
|
||||
auto report = profileFrames(profile);
|
||||
report.update(
|
||||
{{"format", "faset.player-profile"},
|
||||
{"version", 1},
|
||||
{"requested_frames", maximumFrames},
|
||||
{"completed_frames", frames},
|
||||
{"dimension", document.value("dimension", 3)},
|
||||
{"device", stats.device},
|
||||
{"width", renderer.width()},
|
||||
{"height", renderer.height()},
|
||||
{"validation_enabled", stats.validation_enabled},
|
||||
{"validation_errors", stats.validation_errors},
|
||||
{"presentation_mode", headless ? "offscreen" : "windowed"},
|
||||
{"simulation_mode", "synthetic_fixed_timestep"},
|
||||
{"fixed_delta_seconds", config.fixedDelta},
|
||||
{"percentile_method", "nearest_rank_all_completed_frames_no_warmup_exclusion"},
|
||||
{"resource_notes", "gpu_allocated_bytes sums live Vulkan memory allocations, "
|
||||
"including alignment, excluding driver internals. "
|
||||
"texture_count includes the white fallback texture."},
|
||||
{"timing_notes",
|
||||
"Durations use steady_clock wall time, not thread CPU usage. "
|
||||
"render_call/renderer_cpu include GPU waits, readback and window presentation. "
|
||||
"renderer_readback_cpu measures map/copy/unmap wall time within that call. "
|
||||
"GPU timestamps cover submitted rendering, not CPU work. A missing/zero GPU "
|
||||
"timestamp is null. Frame durations exclude profile bookkeeping and final "
|
||||
"capture/profile file writes. Startup begins at main(), excluding OS "
|
||||
"loader/launcher."},
|
||||
{"startup_ms",
|
||||
{{"scene_read", milliseconds(sceneReadStarted, sceneReadFinished)},
|
||||
{"world_initialization", milliseconds(worldStarted, rendererStarted)},
|
||||
{"renderer_initialization", milliseconds(rendererStarted, rendererReady)},
|
||||
{"main_to_first_frame", firstFrameMs}}}});
|
||||
faset::atomic_write_json(profilePath, report);
|
||||
}
|
||||
std::cout << nlohmann::json{{"frames", frames},
|
||||
{"ticks", world.snapshot().tick},
|
||||
{"dimension", document.value("dimension", 3)},
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <faset/core/io.hpp>
|
||||
#include <faset/editor/project_launcher.hpp>
|
||||
#include <fstream>
|
||||
#include <set>
|
||||
#include <thread>
|
||||
|
||||
namespace faset::editor {
|
||||
namespace {
|
||||
namespace fs = std::filesystem;
|
||||
using ui::Kind;
|
||||
using ui::Widget;
|
||||
fs::path user_home() {
|
||||
#ifdef _WIN32
|
||||
if (const auto* value = std::getenv("USERPROFILE"))
|
||||
return fs::path(value);
|
||||
#else
|
||||
if (const auto* value = std::getenv("HOME"))
|
||||
return fs::path(value);
|
||||
#endif
|
||||
return fs::current_path();
|
||||
}
|
||||
fs::path recent_path() {
|
||||
#ifdef _WIN32
|
||||
if (const auto* value = std::getenv("APPDATA"))
|
||||
return fs::path(value) / "Faset/recent-projects.json";
|
||||
#else
|
||||
if (const auto* value = std::getenv("XDG_CONFIG_HOME"); value && *value)
|
||||
return fs::path(value) / "faset/recent-projects.json";
|
||||
#endif
|
||||
return user_home() / ".config/faset/recent-projects.json";
|
||||
}
|
||||
std::string trimmed(std::string text) {
|
||||
const auto first = text.find_first_not_of(" \t\r\n");
|
||||
if (first == std::string::npos)
|
||||
return {};
|
||||
return text.substr(first, text.find_last_not_of(" \t\r\n") - first + 1);
|
||||
}
|
||||
fs::path normalize(std::string text) {
|
||||
text = trimmed(std::move(text));
|
||||
if (text.empty())
|
||||
throw std::runtime_error("Enter a project directory.");
|
||||
if (text.find_first_of("\r\n\t") != std::string::npos || text.find('\0') != std::string::npos)
|
||||
throw std::runtime_error("A directory must fit on one line.");
|
||||
fs::path path = fs::u8path(text);
|
||||
if (text == "~")
|
||||
path = user_home();
|
||||
else if (text.starts_with("~/") || text.starts_with("~\\"))
|
||||
path = user_home() / fs::u8path(text.substr(2));
|
||||
path = fs::weakly_canonical(fs::absolute(path));
|
||||
if (path.filename() == "project.faset.json")
|
||||
path = path.parent_path();
|
||||
return path;
|
||||
}
|
||||
std::string utf8(const fs::path& path) {
|
||||
const auto value = path.u8string();
|
||||
return std::string(reinterpret_cast<const char*>(value.data()), value.size());
|
||||
}
|
||||
ProjectSelection open_project(const fs::path& path) {
|
||||
if (!fs::is_directory(path))
|
||||
throw std::runtime_error("Project directory does not exist.");
|
||||
if (!fs::is_regular_file(path / "project.faset.json"))
|
||||
throw std::runtime_error("No project.faset.json in this directory.");
|
||||
Json data;
|
||||
try {
|
||||
data = read_json(path / "project.faset.json");
|
||||
} catch (...) {
|
||||
throw std::runtime_error("Cannot read project.faset.json.");
|
||||
}
|
||||
if (!data.is_object() || data.value("format", std::string()) != "faset.project" ||
|
||||
data.value("version", 0) != 1)
|
||||
throw std::runtime_error("Unsupported project format or version.");
|
||||
auto name = data.value("name", std::string());
|
||||
const auto dimension = data.value("dimension", 3);
|
||||
if (name.empty() || (dimension != 2 && dimension != 3))
|
||||
throw std::runtime_error("Project name or scene type is invalid.");
|
||||
return {path, std::move(name), dimension, false};
|
||||
}
|
||||
Json recent_records(const fs::path& file) {
|
||||
try {
|
||||
auto j = read_json(file);
|
||||
return j.is_array() ? j : Json::array();
|
||||
} catch (...) {
|
||||
return Json::array();
|
||||
}
|
||||
}
|
||||
void remember(const fs::path& project, const fs::path& store) {
|
||||
const auto selected = open_project(normalize(utf8(project)));
|
||||
auto records = recent_records(store);
|
||||
std::erase_if(records.get_ref<Json::array_t&>(), [&](const Json& record) {
|
||||
return !record.is_string() || record.get<std::string>() == utf8(selected.path);
|
||||
});
|
||||
records.insert(records.begin(), utf8(selected.path));
|
||||
while (records.size() > 12)
|
||||
records.erase(records.end() - 1);
|
||||
atomic_write_json(store, records);
|
||||
}
|
||||
Widget& button(Widget& parent, const std::string& id, const std::string& text,
|
||||
std::function<void()> action) {
|
||||
auto& w = parent.add(Kind::Button, id, text);
|
||||
w.on_click = [action = std::move(action)](Widget&) { action(); };
|
||||
return w;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void remember_project(const fs::path& project) {
|
||||
try {
|
||||
remember(project, recent_path());
|
||||
} catch (...) { /* Recent history is not required to open a project. */
|
||||
}
|
||||
}
|
||||
struct ProjectLauncher::Impl {
|
||||
render::Renderer& renderer;
|
||||
ui::Context ui;
|
||||
render::Snapshot frame_data;
|
||||
fs::path recents_file, browse_path;
|
||||
std::optional<fs::path> pending_browse;
|
||||
std::optional<ProjectSelection> selected;
|
||||
bool create = false, cancelled = false, browsing = false, browser_valid = false;
|
||||
int dimension = 3;
|
||||
std::string error;
|
||||
Impl(render::Renderer& r, const fs::path& engine, const fs::path& initial,
|
||||
const fs::path& recents)
|
||||
: renderer(r), ui(engine / "assets/fonts/NotoSans.ttf"),
|
||||
recents_file(recents.empty() ? recent_path() : recents) {
|
||||
auto theme = ui::Theme::load(engine / "assets/ui/dark.json");
|
||||
theme.font_size = 15;
|
||||
theme.row_height = 30;
|
||||
ui.set_theme(theme);
|
||||
ui.apply_layout(read_json(engine / "assets/ui/project-launcher-layout.json"));
|
||||
ui.set_clipboard([this] { return renderer.clipboard(); },
|
||||
[this](const std::string& value) { renderer.set_clipboard(value); });
|
||||
ui.set_ime(
|
||||
[this](bool enabled) { renderer.set_text_input(enabled); },
|
||||
[this](ui::Rect r) { renderer.set_text_input_area(r.x, r.y, r.width, r.height); });
|
||||
const auto start = initial.empty() ? user_home() / "FasetProjects/MyGame" : initial;
|
||||
ui.update_text("launcher-path", utf8(start));
|
||||
create = initial.empty();
|
||||
ui.find("launcher-open")->on_click = [this](Widget&) { set_mode(false); };
|
||||
ui.find("launcher-create")->on_click = [this](Widget&) { set_mode(true); };
|
||||
ui.find("launcher-2d")->on_click = [this](Widget&) { dimension = 2; };
|
||||
ui.find("launcher-3d")->on_click = [this](Widget&) { dimension = 3; };
|
||||
ui.find("launcher-submit")->selected = true;
|
||||
ui.find("launcher-submit")->on_click = [this](Widget&) { submit(); };
|
||||
ui.find("launcher-cancel")->on_click = [this](Widget&) { cancelled = true; };
|
||||
ui.find("launcher-name")->on_preview = [this](Widget&) { error.clear(); };
|
||||
ui.find("launcher-path")->on_preview = [this](Widget&) { error.clear(); };
|
||||
ui.find("launcher-browse")->on_click = [this](Widget&) { show_browser(); };
|
||||
build_browser();
|
||||
load_recents();
|
||||
refresh();
|
||||
ui.layout(float(renderer.width()), float(renderer.height()));
|
||||
ui.focus(create ? "launcher-name" : "launcher-path");
|
||||
}
|
||||
void set_mode(bool value) {
|
||||
ui.clear_focus();
|
||||
create = value;
|
||||
error.clear();
|
||||
refresh();
|
||||
ui.focus(create ? "launcher-name" : "launcher-path");
|
||||
}
|
||||
void load_recents() {
|
||||
auto& list = *ui.find("launcher-recents");
|
||||
std::size_t i = 0;
|
||||
std::set<fs::path> paths;
|
||||
for (const auto& record : recent_records(recents_file)) {
|
||||
if (!record.is_string())
|
||||
continue;
|
||||
try {
|
||||
const auto selection = open_project(normalize(record.get<std::string>()));
|
||||
if (!paths.insert(selection.path).second)
|
||||
continue;
|
||||
const auto id = "launcher-recent-" + std::to_string(i++);
|
||||
auto& item = list.add(Kind::TreeRow, id, selection.name);
|
||||
item.layout.height = 40;
|
||||
item.tooltip = utf8(selection.path);
|
||||
item.on_click = [this, path = selection.path](Widget&) {
|
||||
ui.update_text("launcher-path", utf8(path), true);
|
||||
set_mode(false);
|
||||
};
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
if (i == 0)
|
||||
list.add(Kind::Label, "launcher-recents-empty", "No recent projects");
|
||||
}
|
||||
void submit() {
|
||||
ui.clear_focus();
|
||||
if (ui.editing())
|
||||
return;
|
||||
try {
|
||||
const auto path = normalize(ui.find("launcher-path")->text);
|
||||
if (create) {
|
||||
const auto name = trimmed(ui.find("launcher-name")->text);
|
||||
if (name.empty())
|
||||
throw std::runtime_error("Enter a project name.");
|
||||
if (name.size() > 256 || name.find_first_of("\r\n\t") != std::string::npos)
|
||||
throw std::runtime_error("Use a short project name on one line.");
|
||||
if (fs::exists(path) && (!fs::is_directory(path) || !fs::is_empty(path)))
|
||||
throw std::runtime_error("Create requires a new or empty directory.");
|
||||
auto parent = path.parent_path();
|
||||
while (!parent.empty() && !fs::exists(parent))
|
||||
parent = parent.parent_path();
|
||||
if (parent.empty() || !fs::is_directory(parent))
|
||||
throw std::runtime_error("Project parent directory is unavailable.");
|
||||
selected = ProjectSelection{path, name, dimension, true};
|
||||
} else
|
||||
selected = open_project(path);
|
||||
error.clear();
|
||||
} catch (const fs::filesystem_error&) {
|
||||
error = "Cannot access this directory.";
|
||||
} catch (const Json::exception&) {
|
||||
error = "Invalid project.faset.json metadata.";
|
||||
} catch (const std::exception& e) {
|
||||
error = e.what();
|
||||
}
|
||||
}
|
||||
void build_browser() {
|
||||
auto& panel = ui.root().add(Kind::Panel, "launcher-browser");
|
||||
panel.layout.absolute = true;
|
||||
panel.layout.padding = 16;
|
||||
panel.layout.gap = 10;
|
||||
panel.visible = false;
|
||||
auto& title = panel.add(Kind::Label, "browser-heading", "Choose project directory");
|
||||
title.font_size = 20;
|
||||
title.layout.height = 35;
|
||||
auto& nav = panel.add(Kind::Row, "browser-navigation");
|
||||
nav.layout.height = 36;
|
||||
button(nav, "browser-up", "Up", [this] {
|
||||
browse_to(browse_path.parent_path());
|
||||
}).layout.width = 60;
|
||||
button(nav, "browser-home", "Home", [this] { browse_to(user_home()); }).layout.width = 76;
|
||||
auto& path = nav.add(Kind::TextField, "browser-path");
|
||||
path.layout.flex = 1;
|
||||
path.on_preview = [this](Widget&) { browser_valid = false; };
|
||||
path.on_commit = [this](Widget& w) {
|
||||
try {
|
||||
pending_browse = normalize(w.text);
|
||||
} catch (const std::exception& e) {
|
||||
error = e.what();
|
||||
}
|
||||
};
|
||||
auto& list = panel.add(Kind::Column, "browser-list");
|
||||
list.layout.flex = 1;
|
||||
list.layout.scroll = true;
|
||||
list.layout.gap = 2;
|
||||
auto& actions = panel.add(Kind::Row, "browser-actions");
|
||||
actions.layout.height = 36;
|
||||
button(actions, "browser-choose", "Choose directory", [this] {
|
||||
if (browser_valid) {
|
||||
ui.update_text("launcher-path", utf8(browse_path), true);
|
||||
close_browser();
|
||||
}
|
||||
}).layout.width = 180;
|
||||
button(actions, "browser-cancel", "Cancel", [this] { close_browser(); }).layout.width = 90;
|
||||
panel.add(Kind::Label, "browser-error");
|
||||
}
|
||||
void browse_to(const fs::path& path) {
|
||||
browser_valid = false;
|
||||
try {
|
||||
const auto normalized = fs::weakly_canonical(fs::absolute(path));
|
||||
if (!fs::is_directory(normalized))
|
||||
throw std::runtime_error("Directory does not exist.");
|
||||
std::vector<fs::path> children;
|
||||
for (const auto& entry : fs::directory_iterator(
|
||||
normalized, fs::directory_options::skip_permission_denied)) {
|
||||
std::error_code status;
|
||||
if (entry.is_directory(status))
|
||||
children.push_back(entry.path());
|
||||
}
|
||||
std::sort(children.begin(), children.end());
|
||||
browse_path = normalized;
|
||||
error.clear();
|
||||
browser_valid = true;
|
||||
auto& list = *ui.find("browser-list");
|
||||
list.children.clear();
|
||||
list.scroll_y = 0;
|
||||
ui.update_text("browser-path", utf8(browse_path), true);
|
||||
std::size_t index = 0;
|
||||
for (const auto& child : children) {
|
||||
auto& row = list.add(Kind::TreeRow, "browser-entry-" + std::to_string(index++),
|
||||
"[Folder] " + utf8(child.filename()));
|
||||
row.layout.height = 34;
|
||||
row.on_click = [this, child](Widget&) {
|
||||
ui.clear_focus();
|
||||
browse_to(child);
|
||||
};
|
||||
}
|
||||
if (children.empty())
|
||||
list.add(Kind::Label, "browser-empty", "No subdirectories");
|
||||
} catch (const fs::filesystem_error&) {
|
||||
error = "Cannot access this directory.";
|
||||
} catch (const std::exception& e) {
|
||||
error = e.what();
|
||||
}
|
||||
}
|
||||
void show_browser() {
|
||||
ui.clear_focus();
|
||||
fs::path path = user_home();
|
||||
try {
|
||||
path = normalize(ui.find("launcher-path")->text);
|
||||
while (!path.empty() && !fs::exists(path))
|
||||
path = path.parent_path();
|
||||
if (!fs::is_directory(path))
|
||||
path = path.parent_path();
|
||||
} catch (...) {
|
||||
}
|
||||
browsing = true;
|
||||
refresh();
|
||||
browse_to(path);
|
||||
ui.focus("browser-path");
|
||||
}
|
||||
void close_browser() {
|
||||
ui.clear_focus(false);
|
||||
browsing = false;
|
||||
error.clear();
|
||||
refresh();
|
||||
ui.focus("launcher-path");
|
||||
}
|
||||
void navigate_pending() {
|
||||
if (pending_browse) {
|
||||
const auto path = *pending_browse;
|
||||
pending_browse.reset();
|
||||
browse_to(path);
|
||||
}
|
||||
}
|
||||
void refresh() {
|
||||
ui.find("launcher-open")->selected = !create;
|
||||
ui.find("launcher-create")->selected = create;
|
||||
ui.find("launcher-2d")->selected = dimension == 2;
|
||||
ui.find("launcher-3d")->selected = dimension == 3;
|
||||
for (const auto* id : {"launcher-name-label", "launcher-name", "launcher-dimension-label",
|
||||
"launcher-dimensions"})
|
||||
ui.find(id)->visible = create;
|
||||
ui.find("launcher-heading")->text = create ? "Create project" : "Open project";
|
||||
ui.find("launcher-submit")->text = create ? "Create project" : "Open project";
|
||||
ui.find("launcher-hint")->text = create ? "2D and 3D can share one project."
|
||||
: "Open a directory containing project.faset.json.";
|
||||
ui.find("launcher-error")->text = browsing ? "" : error;
|
||||
ui.find("browser-error")->text = error;
|
||||
ui.find("browser-choose")->enabled = browser_valid;
|
||||
ui.find("launcher-body")->enabled = !browsing;
|
||||
auto* dialog = ui.find("launcher-browser");
|
||||
dialog->visible = browsing;
|
||||
dialog->layout.width = std::max(320.f, std::min(760.f, float(renderer.width()) - 40));
|
||||
dialog->layout.height = std::max(300.f, std::min(540.f, float(renderer.height()) - 40));
|
||||
dialog->layout.x = (float(renderer.width()) - dialog->layout.width) * .5f;
|
||||
dialog->layout.y = (float(renderer.height()) - dialog->layout.height) * .5f;
|
||||
ui.find("launcher-sidebar")->layout.width =
|
||||
std::clamp(float(renderer.width()) * .24f, 180.f, 235.f);
|
||||
ui.find("launcher-main")->layout.padding = renderer.width() < 850 ? 16 : 32;
|
||||
}
|
||||
void frame(const std::vector<render::Event>& events) {
|
||||
refresh();
|
||||
ui.layout(float(renderer.width()), float(renderer.height()));
|
||||
for (const auto& event : events) {
|
||||
if (event.type == render::Event::Type::Quit)
|
||||
cancelled = true;
|
||||
if (event.type == render::Event::Type::KeyDown && event.key == "Escape") {
|
||||
if (browsing)
|
||||
close_browser();
|
||||
else
|
||||
cancelled = true;
|
||||
continue;
|
||||
}
|
||||
if (event.type == render::Event::Type::KeyDown && event.control &&
|
||||
event.key == "Return") {
|
||||
if (browsing) {
|
||||
ui.clear_focus();
|
||||
navigate_pending();
|
||||
if (!ui.editing() && browser_valid) {
|
||||
ui.update_text("launcher-path", utf8(browse_path), true);
|
||||
close_browser();
|
||||
}
|
||||
} else
|
||||
submit();
|
||||
continue;
|
||||
}
|
||||
if (browsing && event.type == render::Event::Type::MouseDown &&
|
||||
!ui.find("launcher-browser")->rect.contains(event.x, event.y))
|
||||
continue;
|
||||
ui.handle(event);
|
||||
navigate_pending();
|
||||
refresh();
|
||||
ui.layout(float(renderer.width()), float(renderer.height()));
|
||||
}
|
||||
refresh();
|
||||
ui.layout(float(renderer.width()), float(renderer.height()));
|
||||
frame_data = {};
|
||||
frame_data.clear_color = ui.theme().background;
|
||||
ui.draw(frame_data);
|
||||
}
|
||||
};
|
||||
ProjectLauncher::ProjectLauncher(render::Renderer& r, const fs::path& engine,
|
||||
const fs::path& initial, const fs::path& recents)
|
||||
: impl_(std::make_unique<Impl>(r, engine, initial, recents)) {}
|
||||
ProjectLauncher::~ProjectLauncher() = default;
|
||||
void ProjectLauncher::frame(const std::vector<render::Event>& events) {
|
||||
impl_->frame(events);
|
||||
}
|
||||
const render::Snapshot& ProjectLauncher::snapshot() const {
|
||||
return impl_->frame_data;
|
||||
}
|
||||
ui::Context& ProjectLauncher::widgets() {
|
||||
return impl_->ui;
|
||||
}
|
||||
const std::optional<ProjectSelection>& ProjectLauncher::selection() const {
|
||||
return impl_->selected;
|
||||
}
|
||||
bool ProjectLauncher::cancelled() const {
|
||||
return impl_->cancelled;
|
||||
}
|
||||
std::optional<ProjectSelection> run_project_launcher(const fs::path& engine,
|
||||
const fs::path& initial,
|
||||
std::uint64_t max_frames,
|
||||
const fs::path& capture) {
|
||||
render::Renderer renderer({1100, 720, "Faset Engine — Projects", false, true});
|
||||
ProjectLauncher launcher(renderer, engine, initial);
|
||||
std::uint64_t frames = 0;
|
||||
while (!renderer.should_close() && !launcher.cancelled() && !launcher.selection() &&
|
||||
(max_frames == 0 || frames < max_frames)) {
|
||||
launcher.frame(renderer.poll_events());
|
||||
renderer.render(launcher.snapshot());
|
||||
++frames;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1));
|
||||
}
|
||||
if (!capture.empty())
|
||||
renderer.capture(capture);
|
||||
if (renderer.stats().validation_errors)
|
||||
throw std::runtime_error("Project launcher Vulkan validation failed");
|
||||
return launcher.selection();
|
||||
}
|
||||
} // namespace faset::editor
|
||||
Reference in New Issue
Block a user