Author SHA1 Message Date
Emil 5e26c2315e Record build profile and temporal raster cost in lighting benchmark
Native and manual checks / native (ubuntu-24.04) (push) Failing after 31s
Native and manual checks / manual (push) Successful in 28s
Native and manual checks / native (windows-2025) (push) Canceled after 0s
Windows editor and software Vulkan / windows-graphics (push) Canceled after 0s
2026-09-24 02:49:21 +03:00
Emil e074713fed Check real lighting benchmark output and driver identity 2026-09-24 02:43:18 +03:00
Emil d871db1ded Measure P3 lighting sweep and Forward+ threshold 2026-09-24 02:40:11 +03:00
Emil f490254ab3 Add repeatable P3 lighting renderer benchmark 2026-09-24 02:40:03 +03:00
Emil 36a44e14ca Plan bounded sun and local shadows from source casters 2026-09-24 02:26:53 +03:00
Emil cfcfdab949 Bind shared lighting ABI and shade authored local lights 2026-09-24 01:53:14 +03:00
Emil 674e3e812b Expose authored sun, point, and spot lights in render snapshots 2026-09-24 01:35:20 +03:00
50 changed files with 2425 additions and 1887 deletions
+7 -50
View File
@@ -4,7 +4,6 @@
#include <faset/editor/mcp.hpp> #include <faset/editor/mcp.hpp>
#include <faset/editor/session.hpp> #include <faset/editor/session.hpp>
#include <iostream> #include <iostream>
#include <memory>
#include <thread> #include <thread>
#ifdef FASET_HAS_EDITOR_UI #ifdef FASET_HAS_EDITOR_UI
#include <faset/editor/editor_ui.hpp> #include <faset/editor/editor_ui.hpp>
@@ -41,7 +40,7 @@ void help() {
std::cout std::cout
<< "Faset Editor\n" << "Faset Editor\n"
" faset_editor Open the project launcher\n" " faset_editor Open the project launcher\n"
" faset_editor --project PATH [--new NAME --dimension 2|3 [--language cpp|lua]] [--scene RELATIVE_PATH]\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 --mcp [--gui]\n"
" faset_editor --project PATH --command JSON [--wait]\n" " faset_editor --project PATH --command JSON [--wait]\n"
"Options: --engine SDK_SOURCE, --headless, --frames N, --capture PATH.ppm\n" "Options: --engine SDK_SOURCE, --headless, --frames N, --capture PATH.ppm\n"
@@ -53,10 +52,9 @@ int editor_main(int argc, char** argv) {
using namespace faset::editor; using namespace faset::editor;
try { try {
std::filesystem::path project, engine = path_from_utf8(FASET_ENGINE_SOURCE), scene, capture; std::filesystem::path project, engine = path_from_utf8(FASET_ENGINE_SOURCE), scene, capture;
std::string new_name, command, language = "cpp"; std::string new_name, command;
int dimension = 3; int dimension = 3;
bool mcp = false, gui = true, explicit_gui = false, wait = false; bool mcp = false, gui = true, explicit_gui = false, wait = false;
bool explicit_language = false;
std::uint64_t frames = 0; std::uint64_t frames = 0;
for (int i = 1; i < argc; ++i) { for (int i = 1; i < argc; ++i) {
const std::string arg = argv[i]; const std::string arg = argv[i];
@@ -76,10 +74,6 @@ int editor_main(int argc, char** argv) {
new_name = value(); new_name = value();
else if (arg == "--dimension") else if (arg == "--dimension")
dimension = std::stoi(value()); dimension = std::stoi(value());
else if (arg == "--language") {
language = value();
explicit_language = true;
}
else if (arg == "--scene") else if (arg == "--scene")
scene = path_from_utf8(value()); scene = path_from_utf8(value());
else if (arg == "--mcp") else if (arg == "--mcp")
@@ -106,65 +100,30 @@ int editor_main(int argc, char** argv) {
throw Error("cli.option", "Unknown option: " + arg); throw Error("cli.option", "Unknown option: " + arg);
} }
require(!(mcp && !command.empty()), "cli.mode", "Choose MCP or a single command"); require(!(mcp && !command.empty()), "cli.mode", "Choose MCP or a single command");
require(!explicit_language || !new_name.empty(), "cli.language",
"--language requires --new NAME");
require(language == "cpp" || language == "lua", "cli.language",
"Language must be cpp or lua");
if (mcp && !explicit_gui) if (mcp && !explicit_gui)
gui = false; gui = false;
require(!project.empty() || (gui && !mcp && new_name.empty()), "cli.project", require(!project.empty() || (gui && !mcp && new_name.empty()), "cli.project",
"Use --project PATH for command, MCP, or --new modes"); "Use --project PATH for command, MCP, or --new modes");
auto previous_project = project; auto previous_project = project;
#ifdef FASET_HAS_EDITOR_UI
std::optional<ProjectSelection> retry_create;
std::string creation_error;
#endif
for (;;) { for (;;) {
if (project.empty()) { if (project.empty()) {
#ifdef FASET_HAS_EDITOR_UI #ifdef FASET_HAS_EDITOR_UI
const auto selection = const auto selection =
run_project_launcher(engine, previous_project, frames, capture, run_project_launcher(engine, previous_project, frames, capture);
retry_create, creation_error);
retry_create.reset();
creation_error.clear();
if (!selection) if (!selection)
return 0; return 0;
project = selection->path; project = selection->path;
new_name = selection->create ? selection->name : std::string(); new_name = selection->create ? selection->name : std::string();
dimension = selection->dimension; dimension = selection->dimension;
language = selection->language;
explicit_language = selection->create;
#else #else
throw Error("editor.gui_unavailable", throw Error("editor.gui_unavailable",
"This build has no graphical project launcher; use --project PATH"); "This build has no graphical project launcher; use --project PATH");
#endif #endif
} }
std::unique_ptr<Session> session_holder; Session session({std::filesystem::absolute(project), std::filesystem::absolute(engine),
try { executable_directory(argv[0])});
session_holder = std::make_unique<Session>(SessionConfig{ if (!new_name.empty())
std::filesystem::absolute(project), std::filesystem::absolute(engine), session.scaffold(new_name, dimension);
executable_directory(argv[0])});
if (!new_name.empty()) {
if (explicit_language)
session_holder->scaffold(new_name, dimension, language);
else
session_holder->scaffold(new_name, dimension);
}
} catch (const std::exception& error) {
#ifdef FASET_HAS_EDITOR_UI
if (gui && !new_name.empty()) {
retry_create = ProjectSelection{std::filesystem::absolute(project),
new_name, dimension, true, language};
creation_error = error.what();
project.clear();
new_name.clear();
scene.clear();
continue;
}
#endif
throw;
}
Session& session = *session_holder;
const auto settings = session.project(); const auto settings = session.project();
#ifdef FASET_HAS_EDITOR_UI #ifdef FASET_HAS_EDITOR_UI
if (gui && std::filesystem::exists(project / "project.faset.json")) if (gui && std::filesystem::exists(project / "project.faset.json"))
@@ -208,8 +167,6 @@ int editor_main(int argc, char** argv) {
project.clear(); project.clear();
scene.clear(); scene.clear();
new_name.clear(); new_name.clear();
explicit_language = false;
language = "cpp";
continue; continue;
#else #else
throw Error( throw Error(
+10 -52
View File
@@ -75,22 +75,6 @@ ProjectSelection open_project(const fs::path& path) {
throw std::runtime_error("Project name or scene type is invalid."); throw std::runtime_error("Project name or scene type is invalid.");
return {path, std::move(name), dimension, false}; return {path, std::move(name), dimension, false};
} }
bool fresh_destination(const fs::path& path) {
if (!fs::exists(path))
return true;
if (!fs::is_directory(path) || fs::is_symlink(path))
return false;
for (const auto& entry : fs::directory_iterator(path)) {
if (entry.path().filename() != ".faset" || !entry.is_directory() ||
entry.is_symlink())
return false;
for (const auto& internal : fs::directory_iterator(entry.path()))
if (internal.path().filename() != "cache" || !internal.is_directory() ||
internal.is_symlink() || !fs::is_empty(internal.path()))
return false;
}
return true;
}
Json recent_records(const fs::path& file) { Json recent_records(const fs::path& file) {
try { try {
auto j = read_json(file); auto j = read_json(file);
@@ -140,11 +124,9 @@ struct ProjectLauncher::Impl {
std::optional<ProjectSelection> selected; std::optional<ProjectSelection> selected;
bool create = false, cancelled = false, browsing = false, browser_valid = false; bool create = false, cancelled = false, browsing = false, browser_valid = false;
int dimension = 3; int dimension = 3;
std::string language = "cpp";
std::string error; std::string error;
Impl(render::Renderer& r, const fs::path& engine, const fs::path& initial, Impl(render::Renderer& r, const fs::path& engine, const fs::path& initial,
const fs::path& recents, const std::optional<ProjectSelection>& retry, const fs::path& recents)
std::string creation_error)
: renderer(r), ui(engine / "assets/fonts/NotoSans.ttf"), : renderer(r), ui(engine / "assets/fonts/NotoSans.ttf"),
recents_file(recents.empty() ? recent_path() : recents) { recents_file(recents.empty() ? recent_path() : recents) {
auto theme = ui::Theme::load(engine / "assets/ui/dark.json"); auto theme = ui::Theme::load(engine / "assets/ui/dark.json");
@@ -165,22 +147,13 @@ struct ProjectLauncher::Impl {
ui.set_ime( ui.set_ime(
[this](bool enabled) { renderer.set_text_input(enabled); }, [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); }); [this](ui::Rect r) { renderer.set_text_input_area(r.x, r.y, r.width, r.height); });
const auto start = retry ? retry->path const auto start = initial.empty() ? user_home() / "FasetProjects/MyGame" : initial;
: initial.empty() ? user_home() / "FasetProjects/MyGame" : initial;
ui.update_text("launcher-path", path_to_utf8(start)); ui.update_text("launcher-path", path_to_utf8(start));
create = retry ? retry->create : initial.empty(); create = initial.empty();
if (retry) {
ui.update_text("launcher-name", retry->name);
dimension = retry->dimension;
language = retry->language;
error = std::move(creation_error);
}
ui.find("launcher-open")->on_click = [this](Widget&) { set_mode(false); }; 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-create")->on_click = [this](Widget&) { set_mode(true); };
ui.find("launcher-2d")->on_click = [this](Widget&) { dimension = 2; }; ui.find("launcher-2d")->on_click = [this](Widget&) { dimension = 2; };
ui.find("launcher-3d")->on_click = [this](Widget&) { dimension = 3; }; ui.find("launcher-3d")->on_click = [this](Widget&) { dimension = 3; };
ui.find("launcher-cpp")->on_click = [this](Widget&) { language = "cpp"; };
ui.find("launcher-lua")->on_click = [this](Widget&) { language = "lua"; };
ui.find("launcher-submit")->on_click = [this](Widget&) { submit(); }; ui.find("launcher-submit")->on_click = [this](Widget&) { submit(); };
ui.find("launcher-cancel")->on_click = [this](Widget&) { cancelled = true; }; ui.find("launcher-cancel")->on_click = [this](Widget&) { cancelled = true; };
ui.find("launcher-name")->on_preview = [this](Widget&) { error.clear(); }; ui.find("launcher-name")->on_preview = [this](Widget&) { error.clear(); };
@@ -236,14 +209,14 @@ struct ProjectLauncher::Impl {
throw std::runtime_error("Enter a project name."); throw std::runtime_error("Enter a project name.");
if (name.size() > 256 || name.find_first_of("\r\n\t") != std::string::npos) 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."); throw std::runtime_error("Use a short project name on one line.");
if (!fresh_destination(path)) if (fs::exists(path) && (!fs::is_directory(path) || !fs::is_empty(path)))
throw std::runtime_error("Create requires a new or empty directory."); throw std::runtime_error("Create requires a new or empty directory.");
auto parent = path.parent_path(); auto parent = path.parent_path();
while (!parent.empty() && !fs::exists(parent)) while (!parent.empty() && !fs::exists(parent))
parent = parent.parent_path(); parent = parent.parent_path();
if (parent.empty() || !fs::is_directory(parent)) if (parent.empty() || !fs::is_directory(parent))
throw std::runtime_error("Project parent directory is unavailable."); throw std::runtime_error("Project parent directory is unavailable.");
selected = ProjectSelection{path, name, dimension, true, language}; selected = ProjectSelection{path, name, dimension, true};
} else } else
selected = open_project(path); selected = open_project(path);
error.clear(); error.clear();
@@ -378,24 +351,14 @@ struct ProjectLauncher::Impl {
ui.find("launcher-create")->selected = create; ui.find("launcher-create")->selected = create;
ui.find("launcher-2d")->selected = dimension == 2; ui.find("launcher-2d")->selected = dimension == 2;
ui.find("launcher-3d")->selected = dimension == 3; ui.find("launcher-3d")->selected = dimension == 3;
ui.find("launcher-cpp")->selected = language == "cpp";
ui.find("launcher-lua")->selected = language == "lua";
ui.find("launcher-2d")->appearance = ui.find("launcher-2d")->appearance =
dimension == 2 ? Appearance::Quiet : Appearance::Default; dimension == 2 ? Appearance::Quiet : Appearance::Default;
ui.find("launcher-3d")->appearance = ui.find("launcher-3d")->appearance =
dimension == 3 ? Appearance::Quiet : Appearance::Default; dimension == 3 ? Appearance::Quiet : Appearance::Default;
ui.find("launcher-cpp")->appearance =
language == "cpp" ? Appearance::Quiet : Appearance::Default;
ui.find("launcher-lua")->appearance =
language == "lua" ? Appearance::Quiet : Appearance::Default;
ui.find("launcher-language-hint")->text =
language == "cpp" ? "Compiled gameplay. Add Lua later if you need quick reloads."
: "Lua gameplay reloads during development without a C++ rebuild.";
for (const auto* id : {"launcher-name-label", "launcher-name", "launcher-dimension-label", for (const auto* id : {"launcher-name-label", "launcher-name", "launcher-dimension-label",
"launcher-dimensions", "launcher-name-group", "launcher-dimensions", "launcher-name-group",
"launcher-name-space", "launcher-path-space", "launcher-name-space", "launcher-path-space",
"launcher-dimension-group", "launcher-language-group", "launcher-dimension-group"})
"launcher-language-space"})
ui.find(id)->visible = create; ui.find(id)->visible = create;
ui.find("launcher-heading")->text = create ? "Create project" : "Open project"; ui.find("launcher-heading")->text = create ? "Create project" : "Open project";
ui.find("launcher-submit")->text = create ? "Create project" : "Open project"; ui.find("launcher-submit")->text = create ? "Create project" : "Open project";
@@ -461,11 +424,8 @@ struct ProjectLauncher::Impl {
} }
}; };
ProjectLauncher::ProjectLauncher(render::Renderer& r, const fs::path& engine, ProjectLauncher::ProjectLauncher(render::Renderer& r, const fs::path& engine,
const fs::path& initial, const fs::path& recents, const fs::path& initial, const fs::path& recents)
const std::optional<ProjectSelection>& retry, : impl_(std::make_unique<Impl>(r, engine, initial, recents)) {}
std::string creation_error)
: impl_(std::make_unique<Impl>(r, engine, initial, recents, retry,
std::move(creation_error))) {}
ProjectLauncher::~ProjectLauncher() = default; ProjectLauncher::~ProjectLauncher() = default;
void ProjectLauncher::frame(const std::vector<render::Event>& events) { void ProjectLauncher::frame(const std::vector<render::Event>& events) {
impl_->frame(events); impl_->frame(events);
@@ -485,11 +445,9 @@ bool ProjectLauncher::cancelled() const {
std::optional<ProjectSelection> run_project_launcher(const fs::path& engine, std::optional<ProjectSelection> run_project_launcher(const fs::path& engine,
const fs::path& initial, const fs::path& initial,
std::uint64_t max_frames, std::uint64_t max_frames,
const fs::path& capture, const fs::path& capture) {
const std::optional<ProjectSelection>& retry,
std::string creation_error) {
render::Renderer renderer({1100, 720, "Faset Engine — Projects", false, true}); render::Renderer renderer({1100, 720, "Faset Engine — Projects", false, true});
ProjectLauncher launcher(renderer, engine, initial, {}, retry, std::move(creation_error)); ProjectLauncher launcher(renderer, engine, initial);
std::uint64_t frames = 0; std::uint64_t frames = 0;
while (!renderer.should_close() && !launcher.cancelled() && !launcher.selection() && while (!renderer.should_close() && !launcher.cancelled() && !launcher.selection() &&
(max_frames == 0 || frames < max_frames)) { (max_frames == 0 || frames < max_frames)) {
-9
View File
@@ -41,15 +41,6 @@
{"id": "launcher-3d", "kind": "button", "text": "3D", "layout": {"width": 116}} {"id": "launcher-3d", "kind": "button", "text": "3D", "layout": {"width": 116}}
]} ]}
]}, ]},
{"id": "launcher-language-space", "kind": "row", "layout": {"height": 18}},
{"id": "launcher-language-group", "kind": "column", "layout": {"gap": 6}, "children": [
{"id": "launcher-language-label", "kind": "label", "text": "Gameplay language", "layout": {"height": 24}},
{"id": "launcher-languages", "kind": "row", "layout": {"height": 42, "gap": 4}, "children": [
{"id": "launcher-cpp", "kind": "button", "text": "C++", "layout": {"width": 116}},
{"id": "launcher-lua", "kind": "button", "text": "Lua", "layout": {"width": 116}}
]},
{"id": "launcher-language-hint", "kind": "label", "text": "Compiled gameplay. Add Lua later if you need quick reloads.", "font_size": 12, "layout": {"height": 30}}
]},
{"id": "launcher-hint", "kind": "label", "text": "Open a directory containing project.faset.json.", "font_size": 13, "layout": {"height": 36}}, {"id": "launcher-hint", "kind": "label", "text": "Open a directory containing project.faset.json.", "font_size": 13, "layout": {"height": 36}},
{"id": "launcher-actions-space", "kind": "row", "layout": {"height": 22}}, {"id": "launcher-actions-space", "kind": "row", "layout": {"height": 22}},
{"id": "launcher-actions-rule", "kind": "row", "layout": {"height": 1}}, {"id": "launcher-actions-rule", "kind": "row", "layout": {"height": 1}},
+1 -11
View File
@@ -1,18 +1,8 @@
add_library(faset_build_service STATIC add_library(faset_build_service STATIC ${PROJECT_SOURCE_DIR}/src/editor/build_service.cpp)
${PROJECT_SOURCE_DIR}/src/editor/build_service.cpp
${PROJECT_SOURCE_DIR}/src/editor/build_cache.cpp
${PROJECT_SOURCE_DIR}/src/editor/build_diagnostics.cpp)
target_include_directories(faset_build_service PUBLIC ${PROJECT_SOURCE_DIR}/include) target_include_directories(faset_build_service PUBLIC ${PROJECT_SOURCE_DIR}/include)
target_compile_features(faset_build_service PUBLIC cxx_std_20) target_compile_features(faset_build_service PUBLIC cxx_std_20)
target_link_libraries(faset_build_service PUBLIC faset_core faset_scripting_project PRIVATE faset_assets faset_authoring Threads::Threads) target_link_libraries(faset_build_service PUBLIC faset_core faset_scripting_project PRIVATE faset_assets faset_authoring Threads::Threads)
if(BUILD_TESTING) if(BUILD_TESTING)
add_executable(faset_build_diagnostics_tests ${PROJECT_SOURCE_DIR}/tests/build_diagnostics_tests.cpp)
target_link_libraries(faset_build_diagnostics_tests PRIVATE faset_build_service)
add_test(NAME build_diagnostics COMMAND faset_build_diagnostics_tests)
add_executable(faset_build_cache_tests ${PROJECT_SOURCE_DIR}/tests/build_cache_tests.cpp)
target_link_libraries(faset_build_cache_tests PRIVATE faset_build_service)
target_compile_definitions(faset_build_cache_tests PRIVATE FASET_ENGINE_SOURCE="${PROJECT_SOURCE_DIR}")
add_test(NAME build_cache COMMAND faset_build_cache_tests)
add_executable(faset_build_service_tests ${PROJECT_SOURCE_DIR}/tests/build_service_tests.cpp) add_executable(faset_build_service_tests ${PROJECT_SOURCE_DIR}/tests/build_service_tests.cpp)
target_link_libraries(faset_build_service_tests PRIVATE faset_build_service faset_assets) target_link_libraries(faset_build_service_tests PRIVATE faset_build_service faset_assets)
target_compile_definitions(faset_build_service_tests PRIVATE FASET_ENGINE_SOURCE="${PROJECT_SOURCE_DIR}") target_compile_definitions(faset_build_service_tests PRIVATE FASET_ENGINE_SOURCE="${PROJECT_SOURCE_DIR}")
+17 -1
View File
@@ -40,13 +40,17 @@ add_custom_command(OUTPUT "${FASET_SHADER_DIRECTORY}/compatibility.spv"
-o "${FASET_SHADER_DIRECTORY}/compatibility.spv" -o "${FASET_SHADER_DIRECTORY}/compatibility.spv"
DEPENDS "${PROJECT_SOURCE_DIR}/shaders/compatibility.hlsl" VERBATIM) DEPENDS "${PROJECT_SOURCE_DIR}/shaders/compatibility.hlsl" VERBATIM)
add_custom_target(faset_shaders DEPENDS ${FASET_SHADER_OUTPUTS} "${FASET_SHADER_DIRECTORY}/compatibility.spv") add_custom_target(faset_shaders DEPENDS ${FASET_SHADER_OUTPUTS} "${FASET_SHADER_DIRECTORY}/compatibility.spv")
add_library(faset_render "${PROJECT_SOURCE_DIR}/src/render/renderer.cpp" "${PROJECT_SOURCE_DIR}/src/render/math.cpp" "${PROJECT_SOURCE_DIR}/src/render/render_graph.cpp" "${PROJECT_SOURCE_DIR}/src/render/shader_contract.cpp") add_library(faset_render "${PROJECT_SOURCE_DIR}/src/render/renderer.cpp" "${PROJECT_SOURCE_DIR}/src/render/math.cpp" "${PROJECT_SOURCE_DIR}/src/render/render_graph.cpp" "${PROJECT_SOURCE_DIR}/src/render/shader_contract.cpp" "${PROJECT_SOURCE_DIR}/src/render/lighting.cpp")
target_include_directories(faset_render PUBLIC "${PROJECT_SOURCE_DIR}/include") target_include_directories(faset_render PUBLIC "${PROJECT_SOURCE_DIR}/include")
target_compile_features(faset_render PUBLIC cxx_std_20) target_compile_features(faset_render PUBLIC cxx_std_20)
target_link_libraries(faset_render PRIVATE Vulkan::Vulkan SDL3::SDL3 faset_core) target_link_libraries(faset_render PRIVATE Vulkan::Vulkan SDL3::SDL3 faset_core)
target_compile_definitions(faset_render PRIVATE FASET_SHADER_DIRECTORY="${FASET_SHADER_DIRECTORY}") target_compile_definitions(faset_render PRIVATE FASET_SHADER_DIRECTORY="${FASET_SHADER_DIRECTORY}")
add_dependencies(faset_render faset_shaders) add_dependencies(faset_render faset_shaders)
if(BUILD_TESTING) if(BUILD_TESTING)
add_executable(faset_render_lighting_policy_tests "${PROJECT_SOURCE_DIR}/tests/render_lighting_policy_tests.cpp")
target_link_libraries(faset_render_lighting_policy_tests PRIVATE faset_render)
add_test(NAME render_lighting_policy COMMAND faset_render_lighting_policy_tests)
set_tests_properties(render_lighting_policy PROPERTIES LABELS "p3")
add_executable(faset_render_tests "${PROJECT_SOURCE_DIR}/tests/render_tests.cpp") add_executable(faset_render_tests "${PROJECT_SOURCE_DIR}/tests/render_tests.cpp")
target_link_libraries(faset_render_tests PRIVATE faset_render SDL3::SDL3) target_link_libraries(faset_render_tests PRIVATE faset_render SDL3::SDL3)
add_test(NAME render_graph COMMAND faset_render_tests --unit) add_test(NAME render_graph COMMAND faset_render_tests --unit)
@@ -78,5 +82,17 @@ if(BUILD_TESTING)
target_link_libraries(faset_render_window_tests PRIVATE faset_render SDL3::SDL3) target_link_libraries(faset_render_window_tests PRIVATE faset_render SDL3::SDL3)
add_test(NAME render_window_lifecycle COMMAND faset_render_window_tests "${CMAKE_BINARY_DIR}/window-test") add_test(NAME render_window_lifecycle COMMAND faset_render_window_tests "${CMAKE_BINARY_DIR}/window-test")
set_tests_properties(render_window_lifecycle PROPERTIES LABELS "gpu;window" TIMEOUT 40 SKIP_RETURN_CODE 77) set_tests_properties(render_window_lifecycle PROPERTIES LABELS "gpu;window" TIMEOUT 40 SKIP_RETURN_CODE 77)
add_executable(faset_p3_lighting_benchmark
"${PROJECT_SOURCE_DIR}/examples/renderer/p3_lighting_benchmark.cpp")
target_link_libraries(faset_p3_lighting_benchmark PRIVATE faset_render faset_core)
target_compile_definitions(faset_p3_lighting_benchmark PRIVATE
FASET_BENCHMARK_CONFIGURATION="$<CONFIG>")
add_test(NAME render_lighting_benchmark_schema COMMAND
"${Python3_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/tests/test_p3_lighting_benchmark.py")
set_tests_properties(render_lighting_benchmark_schema PROPERTIES LABELS "p3" TIMEOUT 90)
add_test(NAME render_lighting_benchmark_smoke COMMAND
"${Python3_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/tests/test_p3_lighting_benchmark.py"
--real-executable "$<TARGET_FILE:faset_p3_lighting_benchmark>")
set_tests_properties(render_lighting_benchmark_smoke PROPERTIES LABELS "gpu;p3" TIMEOUT 90)
endif() endif()
install(FILES ${FASET_SHADER_OUTPUTS} DESTINATION shaders) install(FILES ${FASET_SHADER_OUTPUTS} DESTINATION shaders)
+92
View File
@@ -0,0 +1,92 @@
# Add lights to a 3D scene
Add a **Light** component to a scene entity. The entity's transform places a point
or spot light; its rotation aims a spot light along local negative Z. A directional
light uses the entity's orientation. Light colors and intensity contribute to the
mesh's linear PBR illumination before tone mapping. Sprites and UI retain their
unlit tint.
The version-1 `faset.light` component has three `kind` values:
| Kind | Position and direction | Useful fields |
| --- | --- | --- |
| `directional` | Direction from the entity transform | `color`, `intensity`, `casts_shadow` |
| `point` | Position from the entity transform; illuminates every direction | `color`, `intensity`, `range` |
| `spot` | Position and local negative-Z direction | `color`, `intensity`, `range`, `inner_angle`, `outer_angle` |
Angles are radians. A spot's inner angle must not exceed its outer angle. Intensity
must be nonnegative and range positive. `enabled: false` keeps the component in the
scene without contributing light. The `shadow_priority` integer is reserved for the
bounded local-shadow scheduler; it does not change brightness.
In the current rendering checkpoint, one enabled directional light can cast the
existing single-map shadow. Point and spot lights illuminate meshes but do not yet
cast shadows. The [P3 lighting plan](https://github.com/emil28092005/Faset_Engine/blob/main/docs/superpowers/plans/2026-09-24-p3-lighting.md)
tracks cascades and the bounded local-shadow atlas. A scene with no Light component
keeps the legacy white sun so older projects retain their appearance. Adding any
Light component, even a disabled one, turns off that compatibility fallback. If
several directionals are enabled, Faset chooses the one with the smallest stable
entity ID and reports a diagnostic for the others.
## Author a point light through MCP
Use `faset_schema` to inspect the current field IDs, then send a `faset_scene_edit`
batch with the document ID, current revision, and target entity ID. For example:
```json
{
"document": "REPLACE_WITH_DOCUMENT_ID",
"revision": 4,
"idempotency_key": "add-red-point-light",
"operations": [{
"op": "component.add",
"entity": "REPLACE_WITH_ENTITY_ID",
"type": "faset.light",
"fields": {
"kind": "point",
"color": [1, 0.15, 0.1, 1],
"intensity": 8,
"range": 6
}
}]
}
```
Move the entity with its Transform component. `component.add` fills any omitted
light fields from the version-1 schema; use `component.set` for later edits. See
[MCP and command line](mcp.md) for revision and retry handling.
## Supply lights directly from C++
When building a `faset::render::Snapshot` yourself, set
`authored_lights_present` to suppress the compatibility sun in a local-only scene.
Provide a stable ID for each light so future shadow scheduling remains independent
of submission order.
```cpp
faset::render::Snapshot snapshot;
snapshot.authored_lights_present = true;
faset::render::LocalLight point;
point.kind = faset::render::LocalLight::Kind::Point;
point.stable_id = "level/torch";
point.position = {-2, 1.5f, 0};
point.color = {1, 0.3f, 0.1f, 1};
point.intensity = 8;
point.range = 6;
snapshot.local_lights.push_back(point);
faset::render::LocalLight spot;
spot.kind = faset::render::LocalLight::Kind::Spot;
spot.stable_id = "level/lamp";
spot.position = {2, 3, 0};
spot.direction = {0, -1, 0};
spot.inner_angle = 0.25f;
spot.outer_angle = 0.55f;
spot.intensity = 5;
spot.range = 9;
snapshot.local_lights.push_back(spot);
```
The renderer submits at most 128 local lights per frame in stable-ID order. Later
P3 work adds explicit overflow diagnostics and measured light-list optimization.
+3 -13
View File
@@ -31,27 +31,17 @@ cmake --build --preset linux-debug --parallel
ctest --preset linux-debug ctest --preset linux-debug
``` ```
Create a runnable C++ or Lua starter and open the native Editor: Create a project and open the native Editor:
```sh ```sh
build/linux-debug/faset_editor --project "$PWD/MyGame" --new MyGame --dimension 3 --language cpp build/linux-debug/faset_editor --project "$PWD/MyGame" --new MyGame --dimension 3
build/linux-debug/faset_editor --project "$PWD/LuaGame" --new LuaGame --dimension 2 --language lua
``` ```
You can also run `build/linux-debug/faset_editor` without arguments to open the You can also run `build/linux-debug/faset_editor` without arguments to open the
project launcher. Its Create view offers C++ 2D, C++ 3D, Lua 2D, and Lua 3D. project launcher and create or select a project using the native interface.
Each explicit starter includes `Scenes/main.scene.json` with a player, ground,
and a behavior bound to `Scripts/Gameplay.cpp` or `Scripts/main.lua`. 3D starters
also include a camera and light. Existing files in a destination directory are
never replaced by starter creation. The older `--new NAME --dimension 2|3`
command without `--language` retains its original scene-free C++ scaffold.
Use **Build** after changing `MyGame/Scripts/Gameplay.cpp`, then **Play**. Use **Build** after changing `MyGame/Scripts/Gameplay.cpp`, then **Play**.
The Player runs separately. Stop it before changing and rebuilding C++ gameplay. The Player runs separately. Stop it before changing and rebuilding C++ gameplay.
Lua starters declare their entry script in `project.faset.json`, install LuaLS
annotations/configuration, and need no `Gameplay.cpp`. Use **Refresh Lua** after
editing a Lua behavior declaration; development **Reload Lua** can apply a valid
script edit to a running Player without recompiling C++.
See [MCP and CLI](../editor/mcp.md) for headless authoring and automation. See [MCP and CLI](../editor/mcp.md) for headless authoring and automation.
For an optimized build use `linux-release`. The `linux-sanitize` preset enables For an optimized build use `linux-release`. The `linux-sanitize` preset enables
-17
View File
@@ -5,23 +5,6 @@ behaviors share the same runtime lifecycle, typed entity operations, scene compo
and Inspector metadata. The Editor does not run gameplay code in its own process. and Inspector metadata. The Editor does not run gameplay code in its own process.
There is no built-in script editor: edit `.lua` files in Zed or another external editor. There is no built-in script editor: edit `.lua` files in Zed or another external editor.
## Start with a runnable Lua project
Choose **Create project → Lua → 2D or 3D** in the native launcher, or run:
```sh
build/linux-debug/faset_editor --project "$PWD/LuaGame" --new LuaGame --dimension 2 --language lua
```
The starter creates `Scripts/main.lua`, a scene with a player and ground, and a
manifest entry for that script. The player moves horizontally and jumps with the
default input actions. The 3D choice also provides a camera and directional light.
Open `Scripts/main.lua` in your external editor and change `speed` or
`fixed_update`. **Refresh Lua** updates declared Inspector fields; **Reload Lua**
updates a development Player after a valid edit without rebuilding native gameplay.
The starter installs LuaLS declarations and `.luarc.json`; reopening it in Zed or
another LuaLS-enabled editor provides completions for the Faset API.
## Enable Lua in a project ## Enable Lua in a project
Add explicit entry scripts to `project.faset.json`: Add explicit entry scripts to `project.faset.json`:
@@ -42,9 +42,9 @@ Tasks 1–3 define the shared interfaces. Integrate Task 2's descriptor ABI befo
**Files:** Modify `include/faset/render/renderer.hpp`, `src/authoring/schema.cpp`, `src/player/SceneView.cpp`, `tests/authoring_tests.cpp`, `tests/runtime_player_tests.cpp`, and relevant Manual authoring examples. **Files:** Modify `include/faset/render/renderer.hpp`, `src/authoring/schema.cpp`, `src/player/SceneView.cpp`, `tests/authoring_tests.cpp`, `tests/runtime_player_tests.cpp`, and relevant Manual authoring examples.
**Interfaces:** Introduce `SunLight { stable_id, direction, color, intensity, casts_shadow }`, `LocalLight { Kind::Point|Spot, stable_id, position, direction, color, intensity, range, inner_angle, outer_angle, casts_shadow, shadow_priority }`, and `CameraFrustum { view, projection, near_plane, far_plane, perspective }`. Add `std::optional<SunLight> Snapshot::sun`, `std::vector<LocalLight> Snapshot::local_lights`, and `std::optional<CameraFrustum> Snapshot::camera_frustum` after existing aggregate fields; retain `Snapshot::light_direction`. `SceneView::build` fills camera data for 3D scenes and picks the first enabled directional by stable ID. **Interfaces:** Introduce `SunLight { stable_id, direction, color, intensity, casts_shadow }`, `LocalLight { Kind::Point|Spot, stable_id, position, direction, color, intensity, range, inner_angle, outer_angle, casts_shadow, shadow_priority }`, and `CameraFrustum { view, projection, near_plane, far_plane, perspective }`. Add `std::optional<SunLight> Snapshot::sun`, `std::vector<LocalLight> Snapshot::local_lights`, `bool Snapshot::authored_lights_present` (default false), and `std::optional<CameraFrustum> Snapshot::camera_frustum` after existing aggregate fields; retain `Snapshot::light_direction`. `SceneView::build` fills camera data for 3D scenes, sets authored-light presence even for disabled/future-version light components, and picks the first enabled directional by stable ID. A legacy sun is synthesized only when both sun and authored-light presence are absent.
- [ ] **Step 1: Write failing schema/extraction tests.** Construct a version-1 scene with directional, point, and spot entities in one order and reversed order. Assert all local IDs/properties agree; authored sun color/intensity are preserved; no-light scene retains the default legacy sun; extra directionals emit a diagnostic; invalid `range <= 0`, `inner_angle > outer_angle`, nonfinite color/transform, and unknown kind identify the entity/field. An essential assertion is: - [ ] **Step 1: Write failing schema/extraction tests.** Construct a version-1 scene with directional, point, and spot entities in one order and reversed order. Assert all local IDs/properties agree; authored sun color/intensity are preserved; a no-light scene has `authored_lights_present == false` and retains the default legacy sun, while a local-only or explicitly disabled-sun scene has the flag true and no sun. Extra directionals emit a diagnostic; invalid `range <= 0`, `inner_angle > outer_angle`, nonfinite color/transform, and unknown kind identify the entity/field. An essential assertion is:
```cpp ```cpp
auto a = view.build(scene_with_three_lights(), 16.f / 9.f); auto a = view.build(scene_with_three_lights(), 16.f / 9.f);
auto b = view.build(reordered_scene_with_three_lights(), 16.f / 9.f); auto b = view.build(reordered_scene_with_three_lights(), 16.f / 9.f);
@@ -54,14 +54,14 @@ Tasks 1–3 define the shared interfaces. Integrate Task 2's descriptor ABI befo
"Light ordering follows stable IDs, not entity array order"); "Light ordering follows stable IDs, not entity array order");
``` ```
- [ ] **Step 2: Run the focused tests red.** Run `cmake --preset linux-debug` and `cmake --build --preset linux-debug --target faset_authoring_tests faset_player_tests --parallel 4`; missing typed fields should fail compilation. If compilation succeeds, `ctest --test-dir build/linux-debug --output-on-failure --no-tests=error -R '^(authoring|player_scene_contracts)$'` must fail on a new behavioral assertion. Record the expected failure rather than assuming the build itself must be red. - [ ] **Step 2: Run the focused tests red.** Run `cmake --preset linux-debug` and `cmake --build --preset linux-debug --target faset_authoring_tests faset_player_tests --parallel 4`; missing typed fields should fail compilation. If compilation succeeds, `ctest --test-dir build/linux-debug --output-on-failure --no-tests=error -R '^(authoring|player_scene_contracts)$'` must fail on a new behavioral assertion. Record the expected failure rather than assuming the build itself must be red.
- [ ] **Step 3: Add schema defaults and extraction.** Keep builtin version 1; use `fields.value` for additive fields, normalize directions after the world transform, validate finite/color/range/cone values, sort by stable ID, and preserve the legacy fallback. Set `camera_frustum` from the same unjittered view/projection used to form `view_projection`. Update direct C++ API examples to construct one point and one spot light. - [ ] **Step 3: Add schema defaults and extraction.** Keep builtin version 1; use `fields.value` for additive fields, normalize directions after the world transform, validate finite/color/range/cone values, sort by stable ID, and preserve the legacy fallback only when no authored light component exists. Set `camera_frustum` from the same unjittered view/projection used to form `view_projection`. Update direct C++ API examples to construct one point and one spot light.
```cpp ```cpp
struct CameraFrustum { struct CameraFrustum {
Mat4 view{identity}, projection{identity}; Mat4 view{identity}, projection{identity};
float near_plane{0.1f}, far_plane{1000.f}; float near_plane{0.1f}, far_plane{1000.f};
bool perspective{true}; bool perspective{true};
}; };
// Snapshot::light_direction remains available to callers without Snapshot::sun. // Snapshot::light_direction remains a fallback only if authored_lights_present is false.
``` ```
- [ ] **Step 4: Run focused tests green.** `ctest --test-dir build/linux-debug --output-on-failure -R '^(authoring|player_scene_contracts)$'` passes, including old version-1 scenes and exported-scene decoding. - [ ] **Step 4: Run focused tests green.** `ctest --test-dir build/linux-debug --output-on-failure -R '^(authoring|player_scene_contracts)$'` passes, including old version-1 scenes and exported-scene decoding.
- [ ] **Step 5: Commit** `Expose authored sun, point, and spot lights in render snapshots`. - [ ] **Step 5: Commit** `Expose authored sun, point, and spot lights in render snapshots`.
@@ -12,7 +12,7 @@ P3 supplies one sun plus point and spot lights, camera-fitted cascaded sun shado
Keep `faset.light` at builtin schema version 1 and add optional fields with defaults: `kind` (`directional`, `point`, `spot`, default `directional`), `enabled` (true), `color` (white), `intensity` (1, nonnegative), `range` (10, positive, for local lights), `inner_angle` (0.35 radians), `outer_angle` (0.70 radians, strictly below π/2), `casts_shadow` (true), and `shadow_priority` (integer 0). Existing fields and component IDs remain valid. Cross-field validation requires `0 <= inner_angle <= outer_angle`; local range, color channels, intensity, transforms, and cone directions must be finite. Invalid authored values return a diagnostic with entity and field rather than nonfinite GPU data. Missing new fields use the schema defaults. Keep `faset.light` at builtin schema version 1 and add optional fields with defaults: `kind` (`directional`, `point`, `spot`, default `directional`), `enabled` (true), `color` (white), `intensity` (1, nonnegative), `range` (10, positive, for local lights), `inner_angle` (0.35 radians), `outer_angle` (0.70 radians, strictly below π/2), `casts_shadow` (true), and `shadow_priority` (integer 0). Existing fields and component IDs remain valid. Cross-field validation requires `0 <= inner_angle <= outer_angle`; local range, color channels, intensity, transforms, and cone directions must be finite. Invalid authored values return a diagnostic with entity and field rather than nonfinite GPU data. Missing new fields use the schema defaults.
`Snapshot` retains `light_direction` for existing direct-render clients. Add an optional `SunLight`, a vector of `LocalLight`, and an optional explicit `CameraFrustum` containing unjittered view/projection matrices, near/far distances, and projection kind. `SceneView` populates these from the authoring scene. Each light carries the stable entity/component identity, transformed position or normalized direction, color, intensity, range, cone angles, and shadow options. The first enabled directional light by stable ID is the sun; extra directionals produce a visible diagnostic until a later multi-sun design exists. If there is no authored directional light, the renderer synthesizes the legacy sun from `Snapshot::light_direction`. Local lights are ordered by stable ID to prevent reordering from changing atlas allocation or results. Point attenuation tends smoothly to zero at `range`; a spot multiplies it by a smooth inner-to-outer cone factor. The shader handles zero distance and invalid normals without NaN output. `Snapshot` retains `light_direction` for existing direct-render clients. Add an optional `SunLight`, a vector of `LocalLight`, an `authored_lights_present` flag (default false), and an optional explicit `CameraFrustum` containing unjittered view/projection matrices, near/far distances, and projection kind. `SceneView` populates these from the authoring scene and sets the flag if any authored light component exists, including a disabled or future-version component. Each light carries the stable entity/component identity, transformed position or normalized direction, color, intensity, range, cone angles, and shadow options. The first enabled directional light by stable ID is the sun; extra directionals produce a visible diagnostic until a later multi-sun design exists. The renderer synthesizes the legacy sun from `Snapshot::light_direction` only when `sun` is absent **and** `authored_lights_present` is false. Thus an old scene without light components keeps its previous appearance, while a local-only scene or explicitly disabled sun does not receive an unintended directional light. Low-level callers may set the flag to request a dark scene without authoring metadata. Local lights are ordered by stable ID to prevent reordering from changing atlas allocation or results. Point attenuation tends smoothly to zero at `range`; a spot multiplies it by a smooth inner-to-outer cone factor. The shader handles zero distance and invalid normals without NaN output.
Explicit camera frustum data is required for four cascades. If a low-level caller supplies only the legacy `view_projection`, the renderer uses one bounded legacy-compatible sun shadow view and reports `effective_sun_cascades = 1`; it never silently claims CSM. 2D sprite-only snapshots do not incur shadow work. The future temporal stage may jitter the main raster projection, but the shadow planner consumes the unjittered `CameraFrustum` exclusively. Explicit camera frustum data is required for four cascades. If a low-level caller supplies only the legacy `view_projection`, the renderer uses one bounded legacy-compatible sun shadow view and reports `effective_sun_cascades = 1`; it never silently claims CSM. 2D sprite-only snapshots do not incur shadow work. The future temporal stage may jitter the main raster projection, but the shadow planner consumes the unjittered `CameraFrustum` exclusively.
@@ -1,42 +0,0 @@
# P1 starter projects: Linux checkpoint (2026-09-24)
This is a focused validation of the four explicit new-project choices in commit
`cbb8b4508723e1a3a0bbd53c884e116a47b121dc`. It is not the final P1 acceptance
record. The run used Linux 7.0.0-31-generic x86_64, Clang 21.1.8, CMake 4.2.3,
and an NVIDIA GeForce RTX 2080 Ti with driver 595.84.
The Editor CLI created C++ 2D, C++ 3D, Lua 2D, and Lua 3D projects in disposable
directories with `--new`, `--dimension`, and `--language`. The test checked the
project manifest, authored start scene, behavior binding, Lua script declaration,
LuaLS files, and the absence of a C++ gameplay stub in Lua-only projects. A real
Debug `faset_build` succeeded for C++ 2D and Lua 3D. Their built Players then
validated and rendered **both** corresponding scene dimensions using the same
language module, so all four templates exercised runtime schema and rendering.
Each headless one-frame render reported `validation_errors: 0` on the physical GPU.
| Project | Built Player used | `--validate` | One-frame headless render |
| --- | --- | --- | --- |
| C++ 2D | C++ 2D | passed | passed |
| C++ 3D | C++ 2D | passed | passed |
| Lua 2D | Lua 3D | passed | passed |
| Lua 3D | Lua 3D | passed | passed |
The C++ and Lua source files are identical within each language choice; the 2D/3D
difference is the authored scene. Reusing one built Player per language therefore
checks scene validity without repeating an identical native compile. The focused
CTest cases `process_and_cook` and `editor_ui_launcher` passed 2/2. They include a
two-service concurrent creation test: exactly one starter wins, and the other
cannot overwrite it. A fresh Lua starter was initialized as a Git repository;
`git check-ignore` confirmed `.faset/cache` is ignored while the LuaLS API
declaration `.faset/lua/faset.lua` can be committed. A strict MkDocs build passed.
These captures are representative output from the C++ 2D and 3D scenes. Lua
scenes rendered the same layout with their own behavior module.
![C++ 2D starter after one frame](p1-starters-linux-2026-09-24/cpp-2.png)
![C++ 3D starter after one frame](p1-starters-linux-2026-09-24/cpp-3.png)
The run did not test Windows, a relocated Release export, multi-frame gameplay
input, or the final P1 build-cache integration. Those belong to the P1 acceptance
matrix and must be recorded separately.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

+233
View File
@@ -0,0 +1,233 @@
#include <faset/core/io.hpp>
#include <faset/render/renderer.hpp>
#include <algorithm>
#include <array>
#include <cstdint>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <stdexcept>
#include <string>
#include <string_view>
using namespace faset::render;
namespace fs = std::filesystem;
namespace {
struct Options {
unsigned lights{}, width{1920}, height{1080}, warmup{10}, frames{30}, run_index{};
bool shadows{}, validation{};
VisibilityMode visibility{VisibilityMode::Direct};
std::string commit{"unknown"}, driver{"unknown"};
fs::path csv, capture;
};
unsigned number(std::string_view text, std::string_view name) {
std::size_t end{};
const auto value = std::stoul(std::string(text), &end);
if (end != text.size() || value > 100000)
throw std::invalid_argument("Invalid value for " + std::string(name));
return static_cast<unsigned>(value);
}
Options parse(int argc, char** argv) {
Options options;
for (int i = 1; i < argc; ++i) {
const std::string name = argv[i];
if (name == "--list-runs") {
std::cout << "{\"lights\":[0,4,16,32,64,128],"
"\"visibility\":[\"direct\",\"gpu-frustum\",\"gpu-occlusion\"],"
"\"shadows\":[\"off\",\"on\"]}\n";
std::exit(0);
}
if (name == "--help") {
std::cout << "Usage: faset_p3_lighting_benchmark --lights 0|4|16|32|64|128 "
"--shadows on|off --visibility direct|gpu-frustum|gpu-occlusion "
"--csv PATH [--width N --height N --warmup N --frames N "
"--run-index N --commit SHA --driver NAME --validation on|off "
"--capture PATH]\n";
std::exit(0);
}
if (i + 1 >= argc)
throw std::invalid_argument("Missing value for " + name);
const std::string value = argv[++i];
if (name == "--lights") options.lights = number(value, name);
else if (name == "--width") options.width = number(value, name);
else if (name == "--height") options.height = number(value, name);
else if (name == "--warmup") options.warmup = number(value, name);
else if (name == "--frames") options.frames = number(value, name);
else if (name == "--run-index") options.run_index = number(value, name);
else if (name == "--commit") options.commit = value;
else if (name == "--driver") options.driver = value;
else if (name == "--csv") options.csv = faset::path_from_utf8(value);
else if (name == "--capture") options.capture = faset::path_from_utf8(value);
else if (name == "--shadows") {
if (value != "on" && value != "off")
throw std::invalid_argument("--shadows must be on or off");
options.shadows = value == "on";
} else if (name == "--validation") {
if (value != "on" && value != "off")
throw std::invalid_argument("--validation must be on or off");
options.validation = value == "on";
} else if (name == "--visibility") {
if (value == "direct") options.visibility = VisibilityMode::Direct;
else if (value == "gpu-frustum") options.visibility = VisibilityMode::GpuFrustum;
else if (value == "gpu-occlusion") options.visibility = VisibilityMode::GpuOcclusion;
else throw std::invalid_argument("Unknown visibility mode: " + value);
} else throw std::invalid_argument("Unknown option: " + name);
}
constexpr std::array allowed_lights{0u, 4u, 16u, 32u, 64u, 128u};
if (options.csv.empty() || options.width == 0 || options.height == 0 ||
options.frames == 0 || options.warmup > 1000 ||
std::find(allowed_lights.begin(), allowed_lights.end(), options.lights) ==
allowed_lights.end())
throw std::invalid_argument("Invalid benchmark configuration");
return options;
}
const char* mode_name(VisibilityMode mode) {
switch (mode) {
case VisibilityMode::Direct: return "direct";
case VisibilityMode::GpuFrustum: return "gpu-frustum";
case VisibilityMode::GpuOcclusion: return "gpu-occlusion";
}
return "unknown";
}
void csv_text(std::ostream& out, std::string_view value) {
out << '"';
for (const char c : value) {
if (c == '"') out << '"';
out << c;
}
out << '"';
}
Snapshot benchmark_scene(const Options& options) {
Snapshot scene;
scene.view_id = "p3-lighting-benchmark-fixed-scene";
scene.eye = {0, 0, 9};
scene.projection = perspective(.9f, float(options.width) / float(options.height), .1f, 100);
const auto view = look_at(scene.eye, {0, 0, 0});
scene.view_projection = multiply(scene.projection, view);
scene.camera_frustum = CameraFrustum{view, scene.projection, .1f, 100, true};
scene.authored_lights_present = true;
scene.clear_color = {.035f, .04f, .05f, 1};
DrawItem receiver;
receiver.mesh = cube_mesh();
receiver.model = transform({0, 0, -.15f}, {}, {10, 7.5f, .2f});
receiver.color = {.65f, .67f, .7f, 1};
receiver.roughness = .65f;
receiver.cast_shadow = options.shadows;
receiver.instance_key = "large-receiver";
scene.draws.push_back(receiver);
for (int i = 0; i < 9; ++i) {
DrawItem object;
object.mesh = cube_mesh();
object.model = transform({(float(i % 3) - 1.f) * 2.5f,
(float(i / 3) - 1.f) * 1.8f, .45f},
{}, {.42f, .42f, .6f});
object.color = {.6f + .1f * float(i % 3), .5f, .4f + .1f * float(i / 3), 1};
object.cast_shadow = options.shadows;
object.instance_key = "caster-" + std::to_string(i);
scene.draws.push_back(std::move(object));
}
for (unsigned i = 0; i < options.lights; ++i) {
LocalLight light;
light.kind = LocalLight::Kind::Point;
light.stable_id = "benchmark-light-" + std::to_string(i);
light.position = {(float(i % 8) - 3.5f) * 1.35f,
(float((i / 8) % 8) - 3.5f) * .95f,
2.f + .35f * float(i % 3)};
light.color = {.6f + .4f * float(i % 3 == 0),
.6f + .4f * float(i % 3 == 1),
.6f + .4f * float(i % 3 == 2), 1};
light.intensity = 5.f;
light.range = 8.f;
light.casts_shadow = options.shadows;
scene.local_lights.push_back(std::move(light));
}
return scene;
}
void benchmark(const Options& options) {
RendererConfig config;
config.width = options.width;
config.height = options.height;
config.headless = true;
config.validation = options.validation;
config.visibility_mode = options.visibility;
auto renderer = Renderer(config);
const auto scene = benchmark_scene(options);
for (unsigned i = 0; i < options.warmup; ++i)
renderer.render(scene);
if (!options.csv.parent_path().empty())
fs::create_directories(faset::native_io_path(options.csv.parent_path()));
std::ofstream csv(faset::native_io_path(options.csv));
if (!csv)
throw std::runtime_error("Cannot open benchmark CSV: " + faset::path_to_utf8(options.csv));
csv << "light_count,shadows,visibility,effective_visibility,lighting_path,"
"build_configuration,run_index,frame,"
"device,driver,commit,width,height,validation_enabled,validation_errors,"
"submitted_local_lights,omitted_local_lights,shadow_tiles,draw_calls,gpu_bytes,"
"gpu_main_raster_ms,gpu_post_raster_ms,gpu_post_visible,visibility_counters_valid,"
"gpu_shadow_ms,gpu_ms,cpu_ms,readback_cpu_ms\n";
csv << std::fixed << std::setprecision(6);
for (unsigned frame = 0; frame < options.frames; ++frame) {
renderer.render(scene);
const auto stats = renderer.stats();
if (stats.validation_errors != 0)
throw std::runtime_error("Vulkan validation error during benchmark");
if (stats.submitted_local_lights != options.lights || stats.omitted_local_lights != 0)
throw std::runtime_error("Renderer did not submit every requested local light");
if (stats.effective_visibility_mode != options.visibility)
throw std::runtime_error("Requested visibility path fell back during benchmark");
if (stats.gpu_main_raster_ms <= 0 || stats.gpu_ms <= 0)
throw std::runtime_error("GPU raster or frame timestamp was unavailable");
csv << options.lights << ',' << (options.shadows ? "on" : "off") << ','
<< mode_name(options.visibility) << ',' << mode_name(stats.effective_visibility_mode)
<< ",forward," << FASET_BENCHMARK_CONFIGURATION << ','
<< options.run_index << ',' << frame << ',';
csv_text(csv, stats.device);
csv << ',';
csv_text(csv, options.driver);
csv << ',';
csv_text(csv, options.commit);
csv << ',' << options.width << ',' << options.height << ','
<< (stats.validation_enabled ? 1 : 0) << ',' << stats.validation_errors << ','
<< stats.submitted_local_lights << ',' << stats.omitted_local_lights
<< ",0," << stats.draw_calls << ',' << stats.gpu_allocated_bytes << ','
<< stats.gpu_main_raster_ms << ',' << stats.gpu_post_raster_ms << ','
<< stats.gpu_post_visible << ',' << (stats.visibility_counters_valid ? 1 : 0)
<< ",0," << stats.gpu_ms << ','
<< stats.cpu_ms << ',' << stats.readback_cpu_ms << '\n';
}
if (!csv)
throw std::runtime_error("Cannot finish benchmark CSV: " + faset::path_to_utf8(options.csv));
if (!options.capture.empty())
renderer.capture(faset::native_io_path(options.capture));
}
} // namespace
int benchmark_main(int argc, char** argv) {
try {
benchmark(parse(argc, argv));
return 0;
} catch (const std::exception& error) {
std::cerr << "P3 lighting benchmark: " << error.what() << '\n';
return 1;
}
}
#ifdef _WIN32
int wmain(int argc, wchar_t** argv) {
return faset::run_utf8_main(argc, argv, benchmark_main);
}
#else
int main(int argc, char** argv) {
return benchmark_main(argc, argv);
}
#endif
-52
View File
@@ -1,52 +0,0 @@
#pragma once
#include <faset/editor/build_service.hpp>
#include <faset/scripting/project.hpp>
#include <filesystem>
#include <optional>
#include <string>
namespace faset::editor {
struct BuildInputs {
std::string source_hash;
std::string recipe_hash;
std::string toolchain_hash;
std::string fingerprint() const;
};
// Hash all files in Scripts and the active Lua declaration, not only the two
// conventional gameplay files. Both Editor stale-state and builds use this key.
std::string gameplay_source_hash(const std::filesystem::path& project_root,
const scripting::LuaProject& lua);
BuildInputs capture_build_inputs(const BuildConfig& config, const scripting::LuaProject& lua);
std::optional<std::string> configured_cmake_value(const BuildConfig& config,
std::string_view key);
// Copy a content-addressed, immutable Scripts tree for the native compiler.
// Returning the same path for equal source bytes preserves Ninja incrementality.
std::filesystem::path stage_gameplay_sources(const BuildConfig& config,
const BuildInputs& inputs,
const scripting::LuaProject& lua,
std::string_view job_id);
// A compiler replaced at the same path may be invisible to Ninja. Invalidate
// only this generated native tree; published build generations stay intact.
void ensure_native_toolchain_stamp(const BuildConfig& config,
const std::filesystem::path& native_directory,
const BuildInputs& inputs);
// Native build completes before package reuse is considered. Hash the actual
// generated outputs, not their timestamps or just the gameplay sources.
std::string build_package_key(const BuildInputs& inputs,
const std::filesystem::path& native_directory,
const std::string& configuration,
const std::filesystem::path& player,
const std::filesystem::path& exporter);
// A hit is accepted only if the complete published file set and schema match
// the manifest. Malformed or old manifests are safe cache misses.
bool validate_build_generation(const std::filesystem::path& directory,
const std::string& package_key);
} // namespace faset::editor
@@ -1,15 +0,0 @@
#pragma once
#include <faset/core/json.hpp>
#include <filesystem>
#include <string_view>
namespace faset::editor {
// Parse compiler, MSVC/clang-cl and Lua output into bounded Editor-facing rows.
// Only sources under the current project's Scripts directory receive a file
// key, so clients cannot offer navigation to unrelated filesystem paths.
Json parse_build_diagnostics(std::string_view raw_log, std::string_view phase,
const std::filesystem::path& project_root);
} // namespace faset::editor
-5
View File
@@ -3,7 +3,6 @@
#include <filesystem> #include <filesystem>
#include <memory> #include <memory>
#include <string> #include <string>
#include <string_view>
#include <vector> #include <vector>
namespace faset::editor { namespace faset::editor {
@@ -23,7 +22,6 @@ struct JobStatus {
std::string id, kind, state{"queued"}, stage{"queued"}; std::string id, kind, state{"queued"}, stage{"queued"};
double progress{}; double progress{};
std::string log, error; std::string log, error;
Json diagnostics = Json::array();
Json result = Json::object(); Json result = Json::object();
bool finished() const { bool finished() const {
return state == "succeeded" || state == "failed" || state == "cancelled"; return state == "succeeded" || state == "failed" || state == "cancelled";
@@ -39,9 +37,6 @@ class BuildService {
BuildService(const BuildService&) = delete; BuildService(const BuildService&) = delete;
BuildService& operator=(const BuildService&) = delete; BuildService& operator=(const BuildService&) = delete;
void scaffold(const std::string& name, int dimension); void scaffold(const std::string& name, int dimension);
// Explicit starter choice creates a runnable start scene. The legacy overload above
// remains intentionally scene-free for existing command-line and API callers.
void scaffold(const std::string& name, int dimension, std::string_view language);
std::string start_build(); std::string start_build();
std::string start_cook(Json resolved_scene); std::string start_cook(Json resolved_scene);
// Publishes output/generations/<id>; current.json changes only after all validation succeeds. // Publishes output/generations/<id>; current.json changes only after all validation succeeds.
+2 -7
View File
@@ -8,7 +8,6 @@ struct ProjectSelection {
std::string name; std::string name;
int dimension = 3; int dimension = 3;
bool create = false; bool create = false;
std::string language = "cpp";
}; };
// Records only existing, valid projects. Call after successful Session setup. // Records only existing, valid projects. Call after successful Session setup.
void remember_project(const std::filesystem::path& project); void remember_project(const std::filesystem::path& project);
@@ -16,9 +15,7 @@ class ProjectLauncher {
public: public:
ProjectLauncher(render::Renderer&, const std::filesystem::path& engine_root, ProjectLauncher(render::Renderer&, const std::filesystem::path& engine_root,
const std::filesystem::path& initial_project = {}, const std::filesystem::path& initial_project = {},
const std::filesystem::path& recent_store = {}, const std::filesystem::path& recent_store = {});
const std::optional<ProjectSelection>& retry = {},
std::string creation_error = {});
~ProjectLauncher(); ~ProjectLauncher();
void frame(const std::vector<render::Event>&); void frame(const std::vector<render::Event>&);
const render::Snapshot& snapshot() const; const render::Snapshot& snapshot() const;
@@ -33,7 +30,5 @@ class ProjectLauncher {
std::optional<ProjectSelection> std::optional<ProjectSelection>
run_project_launcher(const std::filesystem::path& engine_root, run_project_launcher(const std::filesystem::path& engine_root,
const std::filesystem::path& initial_project = {}, const std::filesystem::path& initial_project = {},
std::uint64_t max_frames = 0, const std::filesystem::path& capture = {}, std::uint64_t max_frames = 0, const std::filesystem::path& capture = {});
const std::optional<ProjectSelection>& retry = {},
std::string creation_error = {});
} // namespace faset::editor } // namespace faset::editor
-2
View File
@@ -6,7 +6,6 @@
#include <faset/editor/plugins.hpp> #include <faset/editor/plugins.hpp>
#include <memory> #include <memory>
#include <mutex> #include <mutex>
#include <string_view>
#include <thread> #include <thread>
namespace faset::editor { namespace faset::editor {
@@ -34,7 +33,6 @@ class Session {
return plugins_ ? plugins_->panels() : Json::array(); return plugins_ ? plugins_->panels() : Json::array();
} }
void scaffold(const std::string& name, int dimension); void scaffold(const std::string& name, int dimension);
void scaffold(const std::string& name, int dimension, std::string_view language);
const SessionConfig& config() const { const SessionConfig& config() const {
return config_; return config_;
} }
+76
View File
@@ -0,0 +1,76 @@
#pragma once
#include <faset/render/visibility.hpp>
#include <cstddef>
#include <cstdint>
#include <span>
#include <string>
#include <vector>
namespace faset::render {
enum class ShadowDropReason { None, Unavailable, TileBudget, CasterBudget };
enum class ShadowRedrawReason { EveryFrame };
struct ShadowBudget {
std::uint32_t max_sun_views{4};
std::uint32_t max_local_faces{16};
std::uint32_t max_caster_draws{4096};
std::uint32_t max_local_lights{128};
std::uint32_t sun_atlas_size{2048};
std::uint32_t local_atlas_size{2048};
float max_shadow_distance{80};
bool sun_atlas_available{true};
bool local_atlas_available{true};
};
struct ShadowCasterBounds {
Bounds world;
std::uint32_t draw_index{}; // Index of the original source LOD-0 DrawItem.
};
struct ShadowView {
enum class Kind { Sun, Spot, Point };
Kind kind{Kind::Sun};
std::string light_id;
std::uint32_t face_index{};
std::uint32_t tile_index{};
std::uint32_t tile_origin_x{}, tile_origin_y{}, tile_size{}, usable_size{};
Mat4 view_projection{identity};
std::array<float, 4> atlas_scale_offset{};
std::array<float, 4> guarded_clamp{};
float split_near{}, split_far{};
float snapped_center_x{}, snapped_center_y{};
std::vector<std::uint32_t> caster_indices;
bool valid{};
ShadowDropReason reason{ShadowDropReason::None};
ShadowRedrawReason redraw_reason{ShadowRedrawReason::EveryFrame};
};
struct LocalShadowAssignment {
std::size_t source_index{};
std::uint32_t first_view{};
std::uint32_t face_count{};
bool valid{};
ShadowDropReason reason{ShadowDropReason::None};
};
struct ShadowPlan {
std::vector<ShadowView> sun_views; // Requested slots, including explicitly invalid ones.
std::vector<ShadowView> local_views; // Only complete, valid spot/point allocations.
std::vector<std::size_t> submitted_local_indices; // Priority/influence/stable-ID order.
std::vector<LocalShadowAssignment> local_assignments;
std::uint32_t requested_sun_cascades{}, effective_sun_cascades{};
std::uint32_t local_faces_requested{}, local_faces_used{};
std::uint32_t dropped_sun_views{}, dropped_local_faces{}, dropped_point_faces{};
std::uint32_t omitted_local_lights{}, caster_draws{};
std::uint32_t sun_atlas_size{}, local_atlas_size{};
};
// Stateless: every scheduled tile is cleared/redrawn; no prior-frame depth or
// ownership is reused. Shadow casters are source LOD-0 world bounds, independent
// of the camera/P2 visibility decision. This function performs no Vulkan work.
ShadowPlan build_shadow_plan(const Snapshot& frame,
std::span<const ShadowCasterBounds> casters,
const ShadowBudget& budget = {});
} // namespace faset::render
+35
View File
@@ -80,6 +80,34 @@ struct Text {
Color color{0.85f, 0.87f, 0.90f, 1}; Color color{0.85f, 0.87f, 0.90f, 1};
float size{14}; float size{14};
}; };
struct SunLight {
std::string stable_id;
Vec3 direction{-0.5f, -1, -0.3f};
Color color{1, 1, 1, 1};
float intensity{1};
bool casts_shadow{true};
};
struct LocalLight {
enum class Kind { Point, Spot };
Kind kind{Kind::Point};
std::string stable_id;
Vec3 position{};
Vec3 direction{0, 0, -1};
Color color{1, 1, 1, 1};
float intensity{1};
float range{10};
float inner_angle{0.35f};
float outer_angle{0.7f};
bool casts_shadow{true};
int shadow_priority{};
};
struct CameraFrustum {
Mat4 view{identity};
Mat4 projection{identity};
float near_plane{0.1f};
float far_plane{1000};
bool perspective{true};
};
struct Snapshot { struct Snapshot {
// Optional scene viewport in drawable pixels (x, y, width, height); zero size uses the full // Optional scene viewport in drawable pixels (x, y, width, height); zero size uses the full
// target. // target.
@@ -100,6 +128,12 @@ struct Snapshot {
// Distinguishes temporal histories when one Renderer displays different views. // Distinguishes temporal histories when one Renderer displays different views.
std::string view_id{}; std::string view_id{};
bool camera_cut{}; bool camera_cut{};
// Empty legacy scenes may use the renderer's compatibility sun. Any authored light
// component, including an explicitly disabled or opaque future one, suppresses it.
bool authored_lights_present{};
std::optional<SunLight> sun{};
std::vector<LocalLight> local_lights;
std::optional<CameraFrustum> camera_frustum{};
}; };
enum class VisibilityMode { Direct, GpuFrustum, GpuOcclusion }; enum class VisibilityMode { Direct, GpuFrustum, GpuOcclusion };
// CPU-only validation used before publishing a game or creating Vulkan pipelines. // CPU-only validation used before publishing a game or creating Vulkan pipelines.
@@ -149,6 +183,7 @@ struct FrameStats {
std::uint64_t gpu_allocated_bytes{}; std::uint64_t gpu_allocated_bytes{};
std::uint32_t texture_count{}; std::uint32_t texture_count{};
std::uint32_t vertices{}, draw_calls{}, culled_meshes{}, validation_errors{}; std::uint32_t vertices{}, draw_calls{}, culled_meshes{}, validation_errors{};
std::uint32_t submitted_local_lights{}, omitted_local_lights{};
bool gpu_visibility_active{}, hzb_valid{}; bool gpu_visibility_active{}, hzb_valid{};
// Requested and actual paths for the last frame; actual may be less capable. // Requested and actual paths for the last frame; actual may be less capable.
VisibilityMode requested_visibility_mode{VisibilityMode::Direct}; VisibilityMode requested_visibility_mode{VisibilityMode::Direct};
+1
View File
@@ -44,6 +44,7 @@ nav:
- Editor workspace: editor/workspace.md - Editor workspace: editor/workspace.md
- Scene templates: editor/templates.md - Scene templates: editor/templates.md
- Assets and Blender: editor/assets.md - Assets and Blender: editor/assets.md
- Lighting: editor/lighting.md
- GPU visibility and mesh LOD: editor/visibility-lod.md - GPU visibility and mesh LOD: editor/visibility-lod.md
- Build, Play, and export: editor/export.md - Build, Play, and export: editor/export.md
- Profiling and measurements: editor/profiling.md - Profiling and measurements: editor/profiling.md
+85 -19
View File
@@ -24,6 +24,32 @@ struct FrameParameters {
[[vk::binding(1,0)]] SamplerState shadowSampler; [[vk::binding(1,0)]] SamplerState shadowSampler;
[[vk::binding(2,0)]] Texture2D<float4> colorMap; [[vk::binding(2,0)]] Texture2D<float4> colorMap;
[[vk::binding(3,0)]] SamplerState colorSampler; [[vk::binding(3,0)]] SamplerState colorSampler;
// Shared Direct/P2 graphics ABI. The legacy material set remains set 0;
// GPU-only instance/visibility records occupy set 2.
struct LightingHeader {
uint4 counts; // local count, sun enabled, sun shadow enabled, view count
float4 sunDirectionIntensity; // xyz world-space ray direction, w intensity
float4 sunColor;
float4 cameraForwardShadowDistance;
float4 cascadeSplits;
};
struct LocalLightGpu {
float4 positionRange;
float4 directionCosOuter;
float4 colorIntensity;
float4 coneTypeShadowView; // cos(inner), 0=point/1=spot, shadow view, flags
float4 reserved;
};
struct ShadowViewGpu {
column_major float4x4 viewProjection;
float4 tileScaleOffset;
float4 guardedClamp;
float4 biasFlags;
};
[[vk::binding(0,1)]] StructuredBuffer<LightingHeader> lightingFrame;
[[vk::binding(1,1)]] StructuredBuffer<LocalLightGpu> localLights;
[[vk::binding(2,1)]] StructuredBuffer<ShadowViewGpu> shadowViews;
[[vk::binding(3,1)]] Texture2D<float> localShadowAtlas;
[shader("vertex")] [shader("vertex")]
VertexOutput vertexMain(VertexInput v) { VertexOutput vertexMain(VertexInput v) {
VertexOutput o; VertexOutput o;
@@ -32,6 +58,22 @@ VertexOutput vertexMain(VertexInput v) {
} }
[shader("vertex")] [shader("vertex")]
float4 shadowMain(VertexInput v) : SV_Position { return mul(frame.lightViewProjection, float4(v.world,1)); } float4 shadowMain(VertexInput v) : SV_Position { return mul(frame.lightViewProjection, float4(v.world,1)); }
float3 directBRDF(float3 base, float rough, float metal, float3 n, float3 view, float3 l) {
const float pi = 3.14159265;
float nl = max(dot(n,l),0.0);
if (nl <= 0.0) return float3(0);
float3 halfVector = l + view;
float halfLengthSquared = dot(halfVector, halfVector);
float3 h = halfLengthSquared > 1e-8 ? halfVector * rsqrt(halfLengthSquared) : n;
float nv=max(dot(n,view),0.001), nh=max(dot(n,h),0.0), vh=max(dot(view,h),0.0);
float a=rough*rough, a2=a*a, denom=nh*nh*(a2-1.0)+1.0;
float d=a2/(pi*denom*denom+0.0001);
float k=(rough+1.0)*(rough+1.0)/8.0;
float g=(nl/(nl*(1.0-k)+k))*(nv/(nv*(1.0-k)+k));
float3 f0=lerp(float3(0.04),base,metal), fresnel=f0+(1.0-f0)*pow(1.0-vh,5.0);
float3 spec=d*g*fresnel/max(4.0*nv*nl,0.001);
return ((1.0-fresnel)*(1.0-metal)*base/pi+spec)*nl;
}
[shader("fragment")] [shader("fragment")]
float4 fragmentMain(VertexOutput v) : SV_Target { float4 fragmentMain(VertexOutput v) : SV_Target {
float4 sampled = colorMap.Sample(colorSampler, v.uv); float4 sampled = colorMap.Sample(colorSampler, v.uv);
@@ -41,28 +83,52 @@ float4 fragmentMain(VertexOutput v) : SV_Target {
return v.color * sampled; return v.color * sampled;
} }
float4 base = v.color * sampled; float4 base = v.color * sampled;
const float pi = 3.14159265; LightingHeader lighting = lightingFrame[0];
float3 n=normalize(v.normal), l=normalize(-frame.lightDirection.xyz), view=normalize(frame.eye.xyz-v.world), h=normalize(l+view); float3 n=normalize(v.normal);
float nl=max(dot(n,l),0.0), nv=max(dot(n,view),0.001), nh=max(dot(n,h),0.0), vh=max(dot(view,h),0.0); float3 viewDelta=frame.eye.xyz-v.world;
float viewLengthSquared=dot(viewDelta,viewDelta);
float3 view=viewLengthSquared > 1e-8 ? viewDelta*rsqrt(viewLengthSquared) : n;
float rough=clamp(v.material.x,0.08,1.0), metal=saturate(v.material.y); float rough=clamp(v.material.x,0.08,1.0), metal=saturate(v.material.y);
float a=rough*rough, a2=a*a, denom=nh*nh*(a2-1.0)+1.0; float3 linear=base.rgb*.12;
float d=a2/(pi*denom*denom+0.0001); if (lighting.counts.y != 0 && lighting.sunDirectionIntensity.w > 0) {
float k=(rough+1.0)*(rough+1.0)/8.0; float3 l=normalize(-lighting.sunDirectionIntensity.xyz);
float g=(nl/(nl*(1.0-k)+k))*(nv/(nv*(1.0-k)+k)); float nl=max(dot(n,l),0.0);
float3 f0=lerp(float3(0.04),base.rgb,metal), fresnel=f0+(1.0-f0)*pow(1.0-vh,5.0); float visibility=1.0;
float3 spec=d*g*fresnel/max(4.0*nv*nl,0.001); if (lighting.counts.z != 0 && nl > 0) {
float4 lightClip=mul(frame.lightViewProjection,float4(v.world,1)); float4 lightClip=mul(frame.lightViewProjection,float4(v.world,1));
float3 projected=lightClip.xyz/lightClip.w; float3 projected=lightClip.xyz/lightClip.w;
float2 uv=projected.xy*.5+.5; float2 uv=projected.xy*.5+.5;
float visibility=1.0; if(all(uv>=0.0)&&all(uv<=1.0)&&projected.z>=0.0&&projected.z<=1.0) {
if(all(uv>=0.0)&&all(uv<=1.0)&&projected.z>=0.0&&projected.z<=1.0) { visibility=0.0;
visibility=0.0; for(int y=-1;y<=1;++y) for(int x=-1;x<=1;++x) {
for(int y=-1;y<=1;++y) for(int x=-1;x<=1;++x) { float depth=shadowMap.SampleLevel(shadowSampler,uv+float2(x,y)/1024.0,0);
float depth=shadowMap.SampleLevel(shadowSampler,uv+float2(x,y)/1024.0,0); visibility += projected.z-max(0.0008,0.003*(1.0-nl)) <= depth ? 1.0/9.0 : 0.0;
visibility += projected.z-max(0.0008,0.003*(1.0-nl)) <= depth ? 1.0/9.0 : 0.0; }
}
} }
linear += directBRDF(base.rgb, rough, metal, n, view, l) *
lighting.sunColor.rgb * (lighting.sunDirectionIntensity.w * 3.0 * visibility);
}
for (uint i=0; i<lighting.counts.x; ++i) {
LocalLightGpu light=localLights[i];
float3 delta=light.positionRange.xyz-v.world;
float distanceSquared=max(dot(delta,delta),1e-6);
float distance=sqrt(distanceSquared);
float range=max(light.positionRange.w,1e-4);
if (distance >= range || light.colorIntensity.w <= 0) continue;
float3 l=delta/distance;
float relative=distance/range;
float cutoff=1.0-relative*relative*relative*relative;
float attenuation=cutoff*cutoff/(1.0+distanceSquared);
if (light.coneTypeShadowView.y > 0.5) {
float cosAngle=dot(-l,normalize(light.directionCosOuter.xyz));
float denominator=max(light.coneTypeShadowView.x-light.directionCosOuter.w,1e-4);
float cone=saturate((cosAngle-light.directionCosOuter.w)/denominator);
attenuation *= cone*cone*(3.0-2.0*cone);
}
linear += directBRDF(base.rgb, rough, metal, n, view, l) *
light.colorIntensity.rgb * (light.colorIntensity.w * attenuation);
} }
float3 linear=base.rgb*.12 + ((1.0-fresnel)*(1.0-metal)*base.rgb/pi+spec)*nl*3.0*visibility;
linear=linear/(1.0+linear); linear=linear/(1.0+linear);
return float4(pow(max(linear,0),float3(1.0/2.2)),base.a); return float4(pow(max(linear,0),float3(1.0/2.2)),base.a);
} }
+3 -3
View File
@@ -57,9 +57,9 @@ struct GpuFrameParameters {
uint4 drawInfo; // x=visible ID range base; firstInstance is always zero uint4 drawInfo; // x=visible ID range base; firstInstance is always zero
}; };
[[vk::push_constant]] ConstantBuffer<GpuFrameParameters> gpuFrame; [[vk::push_constant]] ConstantBuffer<GpuFrameParameters> gpuFrame;
[[vk::binding(0,1)]] StructuredBuffer<InstanceRecord> gfxInstances; [[vk::binding(0,2)]] StructuredBuffer<InstanceRecord> gfxInstances;
[[vk::binding(1,1)]] StructuredBuffer<uint> gfxVisibleIds; [[vk::binding(1,2)]] StructuredBuffer<uint> gfxVisibleIds;
[[vk::binding(2,1)]] StructuredBuffer<ViewRecord> gfxViews; [[vk::binding(2,2)]] StructuredBuffer<ViewRecord> gfxViews;
// All indirect commands use firstInstance=0. The raw Vulkan index avoids the // All indirect commands use firstInstance=0. The raw Vulkan index avoids the
// BaseInstance read that Slang adds for SV_InstanceID (DrawParameters feature). // BaseInstance read that Slang adds for SV_InstanceID (DrawParameters feature).
+28 -3
View File
@@ -156,6 +156,15 @@ void SchemaRegistry::validate_component(const Json& component) const {
for (const auto& [id, value] : component["fields"].items()) for (const auto& [id, value] : component["fields"].items())
if (metadata["fields"].contains(id)) if (metadata["fields"].contains(id))
validate_field(value, metadata["fields"][id]); validate_field(value, metadata["fields"][id]);
if (type == "faset.light") {
auto effective = default_fields(type);
effective.update(component.at("fields"));
if (effective.at("kind") == "spot")
require(effective.at("inner_angle").get<double>() <=
effective.at("outer_angle").get<double>(),
"validation.light_cone",
"Spotlight inner_angle must not exceed outer_angle");
}
} }
void SchemaRegistry::add_migration(const std::string& type, int from_version, Json rules) { void SchemaRegistry::add_migration(const std::string& type, int from_version, Json rules) {
require(contains(type) && from_version > 0 && from_version < schema(type).value("version", 1) && require(contains(type) && from_version > 0 && from_version < schema(type).value("version", 1) &&
@@ -252,9 +261,25 @@ SchemaRegistry builtin_schemas() {
{{"fov", Json{{"type", "number"}, {"default", 60.0}, {"min", 1.0}, {"max", 179.0}}}, {{"fov", Json{{"type", "number"}, {"default", 60.0}, {"min", 1.0}, {"max", 179.0}}},
{"near", Json{{"type", "number"}, {"default", 0.1}, {"min", 0.001}}}, {"near", Json{{"type", "number"}, {"default", 0.1}, {"min", 0.001}}},
{"far", Json{{"type", "number"}, {"default", 1000.0}, {"min", 0.01}}}}); {"far", Json{{"type", "number"}, {"default", 1000.0}, {"min", 0.01}}}});
add("faset.light", "Directional Light", add("faset.light", "Light",
{{"color", field("color", {1, 1, 1, 1})}, {{"kind", Json{{"type", "string"},
{"intensity", Json{{"type", "number"}, {"default", 1.0}, {"min", 0.0}}}}); {"default", "directional"},
{"enum", {"directional", "point", "spot"}}}},
{"enabled", field("boolean", true)},
{"color", field("color", {1, 1, 1, 1})},
{"intensity", Json{{"type", "number"}, {"default", 1.0}, {"min", 0.0}}},
{"range", Json{{"type", "number"}, {"default", 10.0}, {"min", 0.001}}},
{"inner_angle", Json{{"type", "number"},
{"default", 0.35}, {"min", 0.0}, {"max", 1.55},
{"unit", "radians"}}},
{"outer_angle", Json{{"type", "number"},
{"default", 0.7}, {"min", 0.001}, {"max", 1.55},
{"unit", "radians"}}},
{"casts_shadow", field("boolean", true)},
{"shadow_priority", Json{{"type", "integer"},
{"default", 0},
{"min", std::numeric_limits<int>::min()},
{"max", std::numeric_limits<int>::max()}}}});
for (int dimension : {2, 3}) { for (int dimension : {2, 3}) {
Json vector = dimension == 2 ? Json{0, 0} : Json{0, 0, 0}; Json vector = dimension == 2 ? Json{0, 0} : Json{0, 0, 0};
Json extents = dimension == 2 ? Json{0.5, 0.5} : Json{0.5, 0.5, 0.5}; Json extents = dimension == 2 ? Json{0.5, 0.5} : Json{0.5, 0.5, 0.5};
-393
View File
@@ -1,393 +0,0 @@
#include <faset/editor/build_cache.hpp>
#include <faset/authoring/schema.hpp>
#include <faset/core/hash.hpp>
#include <faset/core/io.hpp>
#include <faset/core/process.hpp>
#include <cctype>
#include <cstdlib>
#include <set>
#include <stdexcept>
namespace faset::editor {
namespace fs = std::filesystem;
namespace {
std::string normalized_hash(const Json& value) {
return sha256(value.dump());
}
std::pair<std::string, std::string> parse_define(const std::string& argument) {
if (!argument.starts_with("-D"))
throw std::invalid_argument("Gameplay configure arguments must be -DNAME=value options");
const auto equal = argument.find('=', 2);
if (equal == std::string::npos || equal == 2)
throw std::invalid_argument("Invalid gameplay configure definition");
const auto colon = argument.find(':', 2);
const auto key = argument.substr(2, colon < equal ? colon - 2 : equal - 2);
if (key.empty())
throw std::invalid_argument("Invalid gameplay configure definition");
return {key, argument.substr(equal + 1)};
}
void validate_configure_arguments(const BuildConfig& config) {
static const std::set<std::string> controlled = {
"FASET_GAMEPLAY_SOURCE_DIR", "FASET_ENABLE_LUA", "CMAKE_BUILD_TYPE", "BUILD_TESTING",
"FASET_BUILD_EDITOR", "FASET_BUILD_RENDERER", "FASET_BUILD_RUNTIME", "FASET_BUILD_ASSETS"};
for (const auto& argument : config.configure_arguments)
if (controlled.contains(parse_define(argument).first))
throw std::invalid_argument("Cannot override an engine-controlled CMake definition");
}
std::string configured_tool(const BuildConfig& config, std::string_view key,
std::string fallback) {
if (auto value = configured_cmake_value(config, key))
return *value;
return fallback;
}
fs::path resolve_tool(const std::string& name, const fs::path& working_directory) {
const auto supplied = path_from_utf8(name);
if (supplied.has_parent_path())
return (supplied.is_absolute() ? supplied : working_directory / supplied).lexically_normal();
try {
return find_executable(name);
} catch (const std::exception&) {
// CMake reports a missing compiler. Keep the identity deterministic so
// fixture build tools can exercise publication without native compiling.
return supplied;
}
}
Json tool_identity(const std::string& name, const fs::path& working_directory) {
const auto resolved = resolve_tool(name, working_directory);
Json result = {{"path", path_to_utf8(resolved)}};
if (fs::is_regular_file(resolved))
result["sha256"] = sha256_file(resolved);
else
result["missing"] = true;
return result;
}
std::string slang_name(const BuildConfig& config) {
auto configured = configured_tool(config, "SLANGC_EXECUTABLE", "");
if (!configured.empty())
return configured;
#ifdef _WIN32
const auto local = config.engine_root / ".cache/slang/bin/slangc.exe";
#else
const auto local = config.engine_root / ".cache/slang/bin/slangc";
#endif
if (fs::is_regular_file(local))
return path_to_utf8(local);
if (const char* sdk = std::getenv("VULKAN_SDK")) {
#ifdef _WIN32
const auto sdk_tool = path_from_utf8(sdk) / "Bin/slangc.exe";
#else
const auto sdk_tool = path_from_utf8(sdk) / "bin/slangc";
#endif
if (fs::is_regular_file(sdk_tool))
return path_to_utf8(sdk_tool);
}
return "slangc";
}
Json recipe_files(const fs::path& engine_root) {
Json files = Json::object();
for (const auto& relative : {"CMakeLists.txt", "dependencies.lock.json",
"tools/compile_shader.py"}) {
const auto path = engine_root / relative;
files[relative] = fs::is_regular_file(path) ? Json(sha256_file(path)) : Json(nullptr);
}
const auto cmake = engine_root / "cmake";
if (fs::is_directory(cmake))
for (const auto& entry : fs::directory_iterator(cmake))
if (entry.is_regular_file() && entry.path().extension() == ".cmake")
files[generic_path_to_utf8(entry.path().lexically_relative(engine_root))] =
sha256_file(entry.path());
return files;
}
bool within(const fs::path& path, const fs::path& directory) {
const auto relative = path.lexically_relative(directory);
if (relative.empty() || relative.is_absolute())
return false;
for (const auto& component : relative)
if (component == "..")
return false;
return true;
}
void refuse_symlink_ancestors(const fs::path& path) {
fs::path prefix;
for (const auto& component : path) {
prefix /= component;
std::error_code error;
if (fs::is_symlink(fs::symlink_status(prefix, error)))
throw std::runtime_error("Native build path contains a symlink: " +
path_to_utf8(prefix));
}
}
} // namespace
std::optional<std::string> configured_cmake_value(const BuildConfig& config,
std::string_view key) {
std::optional<std::string> result;
for (const auto& argument : config.configure_arguments) {
const auto [name, value] = parse_define(argument);
if (name == key)
result = value;
}
return result;
}
std::string BuildInputs::fingerprint() const {
return normalized_hash({{"format", "faset.build-inputs.v1"},
{"source", source_hash},
{"recipe", recipe_hash},
{"toolchain", toolchain_hash}});
}
std::string gameplay_source_hash(const fs::path& project_root, const scripting::LuaProject& lua) {
Json files = Json::object();
const auto scripts = project_root / "Scripts";
if (fs::is_symlink(fs::symlink_status(scripts)))
throw std::runtime_error("Scripts directory must not be a symlink");
if (fs::is_directory(scripts))
for (const auto& entry : fs::recursive_directory_iterator(scripts)) {
if (entry.is_symlink())
throw std::runtime_error("Scripts source cannot be a symlink: " +
path_to_utf8(entry.path()));
if (entry.is_regular_file())
files[generic_path_to_utf8(entry.path().lexically_relative(project_root))] =
sha256_file(entry.path());
}
return normalized_hash({{"format", "faset.gameplay-sources.v1"},
{"files", files},
{"lua", lua.fingerprint}});
}
BuildInputs capture_build_inputs(const BuildConfig& config, const scripting::LuaProject& lua) {
validate_configure_arguments(config);
BuildInputs result;
result.source_hash = gameplay_source_hash(config.project_root, lua);
result.recipe_hash = normalized_hash({{"format", "faset.build-recipe.v1"},
{"generator", config.generator},
{"configuration", config.configuration},
{"export_configuration", config.export_configuration},
{"configure_arguments", config.configure_arguments},
{"engine_files", recipe_files(config.engine_root)}});
#ifdef _WIN32
constexpr auto default_c = "clang-cl", default_cxx = "clang-cl";
#else
constexpr auto default_c = "clang", default_cxx = "clang++";
#endif
result.toolchain_hash = normalized_hash(
{{"format", "faset.native-toolchain.v1"},
{"cmake", tool_identity(config.cmake, config.project_root)},
{"c", tool_identity(configured_tool(config, "CMAKE_C_COMPILER", default_c),
config.project_root)},
{"cxx", tool_identity(configured_tool(config, "CMAKE_CXX_COMPILER", default_cxx),
config.project_root)},
{"slang", tool_identity(slang_name(config), config.project_root)},
{"toolchain_file", tool_identity(configured_tool(config, "CMAKE_TOOLCHAIN_FILE", ""),
config.project_root)}});
return result;
}
fs::path stage_gameplay_sources(const BuildConfig& config, const BuildInputs& inputs,
const scripting::LuaProject& lua, std::string_view job_id) {
const auto cache = config.cache_root.empty() ? config.project_root / ".faset" / "cache"
: config.cache_root;
const auto snapshots = cache / "source-snapshots";
const auto destination = snapshots / inputs.source_hash;
const auto verified = [&](const fs::path& root) {
return fs::is_directory(root / "Scripts") && !fs::is_symlink(root) &&
gameplay_source_hash(root, lua) == inputs.source_hash;
};
if (fs::exists(destination)) {
if (!verified(destination))
throw std::runtime_error("Cached gameplay source snapshot is corrupt");
return destination / "Scripts";
}
const auto source = config.project_root / "Scripts";
if (!fs::is_directory(source) || fs::is_symlink(source))
throw std::runtime_error("Gameplay Scripts directory is missing or a symlink");
const auto staging = snapshots / (".staging-" + std::string(job_id));
if (fs::exists(staging))
throw std::runtime_error("Gameplay source staging directory already exists");
fs::create_directories(staging / "Scripts");
try {
for (const auto& entry : fs::recursive_directory_iterator(source)) {
if (entry.is_symlink())
throw std::runtime_error("Gameplay source snapshot contains a symlink");
const auto target = staging / "Scripts" / entry.path().lexically_relative(source);
if (entry.is_directory())
fs::create_directories(target);
else if (entry.is_regular_file()) {
fs::create_directories(target.parent_path());
fs::copy_file(entry.path(), target);
}
}
if (!verified(staging))
throw std::runtime_error("Gameplay sources changed while creating the build snapshot");
fs::rename(staging, destination);
return destination / "Scripts";
} catch (...) {
std::error_code ignored;
fs::remove_all(staging, ignored);
if (fs::exists(destination) && verified(destination))
return destination / "Scripts";
throw;
}
}
void ensure_native_toolchain_stamp(const BuildConfig& config, const fs::path& native_directory,
const BuildInputs& inputs) {
const auto project = fs::absolute(config.project_root).lexically_normal();
const auto base = fs::absolute(config.build_directory).lexically_normal();
const auto native = fs::absolute(native_directory).lexically_normal();
const auto cache = fs::absolute(config.cache_root.empty()
? project / ".faset" / "cache"
: config.cache_root).lexically_normal();
const auto engine = fs::absolute(config.engine_root).lexically_normal();
if (native.parent_path() != base ||
(native.filename() != "Debug" && native.filename() != "Release" &&
native.filename() != "RelWithDebInfo"))
throw std::runtime_error("Native build path is outside its configured managed root");
const auto default_base = project / ".faset" / "build";
if ((within(base, project) && base != default_base) || within(project, base) ||
within(base, cache) || within(cache, base) || within(base, engine) ||
within(engine, base))
throw std::runtime_error("Native build root overlaps project, cache or engine files");
refuse_symlink_ancestors(native);
const auto stamp = native_directory / ".faset-toolchain.json";
bool matching = false, owned = false;
if (fs::is_regular_file(stamp) && !fs::is_symlink(stamp))
try {
const auto existing = read_json(stamp);
const auto format = existing.value("format", std::string()) ==
"faset.toolchain-stamp";
const auto version = existing.value("version", 0);
owned = format &&
((version == 2 &&
existing.value("project_root", std::string()) == path_to_utf8(project) &&
existing.value("native_directory", std::string()) == path_to_utf8(native)) ||
(version == 1 && base == default_base));
matching = owned &&
existing.value("toolchain_hash", std::string()) == inputs.toolchain_hash;
} catch (const std::exception&) {
owned = false;
}
if (fs::exists(native_directory) && !owned)
throw std::runtime_error("Refusing to clear an unowned native build directory: " +
path_to_utf8(native));
if (!matching && fs::exists(native_directory))
fs::remove_all(native_directory);
fs::create_directories(native_directory);
atomic_write_json(stamp, {{"format", "faset.toolchain-stamp"},
{"version", 2},
{"project_root", path_to_utf8(project)},
{"native_directory", path_to_utf8(native)},
{"toolchain_hash", inputs.toolchain_hash}});
}
std::string build_package_key(const BuildInputs& inputs, const fs::path& native_directory,
const std::string& configuration, const fs::path& player,
const fs::path& exporter) {
Json files = Json::object();
for (const auto& [name, path] :
{std::pair{"player", player}, std::pair{"exporter", exporter},
std::pair{"cmake_cache", native_directory / "CMakeCache.txt"}}) {
if (!fs::is_regular_file(path) || fs::is_symlink(path))
throw std::runtime_error("Native build artifact is missing: " + path_to_utf8(path));
files[name] = sha256_file(path);
}
const auto shaders = native_directory / "shaders";
if (!fs::is_directory(shaders))
throw std::runtime_error("Native shader directory is missing");
for (const auto& entry : fs::recursive_directory_iterator(shaders)) {
if (entry.is_symlink())
throw std::runtime_error("Native shader is a symlink");
if (entry.is_regular_file())
files[generic_path_to_utf8(entry.path().lexically_relative(native_directory))] =
sha256_file(entry.path());
}
// On Windows, a changed runtime DLL must also invalidate a package hit.
for (const auto& root : {player.parent_path(), native_directory,
native_directory / configuration}) {
if (!fs::is_directory(root))
continue;
for (const auto& entry : fs::directory_iterator(root)) {
auto extension = path_to_utf8(entry.path().extension());
for (auto& character : extension)
character = static_cast<char>(std::tolower(static_cast<unsigned char>(character)));
if (extension != ".dll")
continue;
if (!entry.is_regular_file() || entry.is_symlink())
throw std::runtime_error("Native runtime DLL is not a regular file");
files["dll:" + path_to_utf8(entry.path().filename())] = sha256_file(entry.path());
}
}
return normalized_hash({{"format", "faset.package-key.v1"},
{"inputs", inputs.fingerprint()},
{"configuration", configuration},
{"files", files}});
}
bool validate_build_generation(const fs::path& directory, const std::string& package_key) {
try {
if (!fs::is_directory(directory) || fs::is_symlink(directory))
return false;
const auto manifest_file = directory / "manifest.json";
if (!fs::is_regular_file(manifest_file) || fs::is_symlink(manifest_file))
return false;
const auto manifest = read_json(manifest_file);
if (manifest.at("format") != "faset.build" || manifest.at("version") != 2 ||
manifest.at("package_key") != package_key || !manifest.at("files").is_array())
return false;
std::set<std::string> expected;
for (const auto& record : manifest.at("files")) {
const auto name = record.at("path").get<std::string>();
const auto relative = path_from_utf8(name);
if (relative.empty() || relative.is_absolute() || name == "manifest.json" ||
generic_path_to_utf8(relative) != name)
return false;
for (const auto& component : relative)
if (component == "." || component == "..")
return false;
if (!expected.insert(name).second)
return false;
auto file = directory;
for (const auto& component : relative) {
file /= component;
if (fs::is_symlink(file))
return false;
}
if (!fs::is_regular_file(file) || fs::is_symlink(file) ||
sha256_file(file) != record.at("sha256").get<std::string>() ||
fs::file_size(file) != record.at("size").get<std::uintmax_t>())
return false;
}
for (const auto& entry : fs::recursive_directory_iterator(directory)) {
if (entry.is_symlink())
return false;
if (entry.is_regular_file()) {
const auto name = generic_path_to_utf8(entry.path().lexically_relative(directory));
if (name != "manifest.json" && !expected.contains(name))
return false;
}
}
const auto player_name = manifest.at("player").get<std::string>();
const auto schema_name = manifest.at("schema").get<std::string>();
if (!expected.contains(player_name) || !expected.contains(schema_name) ||
!expected.contains("faset_schema_exporter" + fs::path(player_name).extension().string()))
return false;
for (const auto* shader : {"vertexMain", "fragmentMain", "shadowMain",
"gpuVertexMain", "gpuShadowMain", "gpuCullMain",
"gpuHzbMain", "gpuPostCullMain"})
for (const auto* extension : {".spv", ".reflection.json"})
if (!expected.contains("shaders/" + std::string(shader) + extension))
return false;
const auto schema = read_json(directory / schema_name);
if (schema.at("format") != "faset.schema" || schema.at("version") != 1 ||
schema.at("build_fingerprint") != manifest.at("fingerprint"))
return false;
(void)authoring::gameplay_schemas(schema);
return true;
} catch (const std::exception&) {
return false;
}
}
} // namespace faset::editor
-147
View File
@@ -1,147 +0,0 @@
#include <faset/editor/build_diagnostics.hpp>
#include <faset/core/io.hpp>
#include <algorithm>
#include <cctype>
#include <regex>
#include <sstream>
namespace faset::editor {
namespace {
constexpr std::size_t max_rows = 200;
constexpr std::size_t max_line = 8192;
constexpr std::size_t max_message = 2048;
std::string strip_ansi(std::string_view text) {
std::string clean;
clean.reserve(text.size());
for (std::size_t index = 0; index < text.size(); ++index) {
if (text[index] == '\x1b' && index + 1 < text.size() && text[index + 1] == '[') {
index += 2;
while (index < text.size() && !(text[index] >= '@' && text[index] <= '~'))
++index;
continue;
}
clean += text[index];
}
return clean;
}
std::string normalize_path(std::string path) {
std::replace(path.begin(), path.end(), '\\', '/');
path = std::filesystem::path(path).lexically_normal().generic_string();
while (path.size() > 1 && path.back() == '/')
path.pop_back();
return path;
}
bool has_windows_drive(std::string_view path) {
return path.size() >= 2 && std::isalpha(static_cast<unsigned char>(path[0])) &&
path[1] == ':';
}
std::string lower_ascii(std::string value) {
for (auto& character : value)
character = static_cast<char>(std::tolower(static_cast<unsigned char>(character)));
return value;
}
std::string project_source(std::string raw, const std::filesystem::path& project_root) {
if (raw.starts_with("lua: "))
raw.erase(0, 5);
std::replace(raw.begin(), raw.end(), '\\', '/');
for (const auto& component : std::filesystem::path(raw))
if (component == "..")
return {};
const auto path = normalize_path(std::move(raw));
const auto root = normalize_path(path_to_utf8(project_root));
std::string relative;
if (!path.empty() && path[0] != '/' && !has_windows_drive(path))
relative = path;
else {
const auto windows = has_windows_drive(path) && has_windows_drive(root);
const auto comparable_path = windows ? lower_ascii(path) : path;
const auto comparable_root = windows ? lower_ascii(root) : root;
if (comparable_path.size() <= comparable_root.size() ||
!comparable_path.starts_with(comparable_root) ||
comparable_path[comparable_root.size()] != '/')
return {};
relative = path.substr(root.size() + 1);
}
if (!relative.starts_with("Scripts/") || relative.size() <= 8)
return {};
if (relative.find("/../") != std::string::npos || relative.ends_with("/.."))
return {};
return relative;
}
int positive_number(const std::string& text) {
try {
const auto value = std::stoll(text);
return value > 0 && value <= 100000000 ? static_cast<int>(value) : 0;
} catch (const std::exception&) {
return 0;
}
}
} // namespace
Json parse_build_diagnostics(std::string_view raw_log, std::string_view phase,
const std::filesystem::path& project_root) {
static const std::regex msvc(
R"(^(.+)\(([0-9]+)(?:,([0-9]+))?\)\s*:\s*(fatal error|error|warning|note)\s*([A-Za-z]+[0-9]+)?\s*:\s*(.*)$)");
static const std::regex clang_column(
R"(^(.+):([0-9]+):([0-9]+):\s*(fatal error|error|warning|note)(?:\s+([A-Za-z]+[0-9]+))?\s*:\s*(.*)$)");
static const std::regex clang_line(
R"(^(.+):([0-9]+):\s*(fatal error|error|warning|note)(?:\s+([A-Za-z]+[0-9]+))?\s*:\s*(.*)$)");
static const std::regex lua(R"(^(.+\.lua):([0-9]+):\s*(.*)$)");
Json rows = Json::array();
std::istringstream stream{std::string(raw_log)};
std::string raw;
while (rows.size() < max_rows && std::getline(stream, raw)) {
if (raw.size() > max_line)
raw.resize(max_line);
auto line = strip_ansi(raw);
if (!line.empty() && line.back() == '\r')
line.pop_back();
std::smatch match;
std::string file, message, severity, code;
int row = 0, column = 0;
bool has_column = false;
if (std::regex_match(line, match, msvc) ||
std::regex_match(line, match, clang_column)) {
file = match[1].str();
row = positive_number(match[2].str());
has_column = !match[3].str().empty();
column = positive_number(match[3].str());
severity = match[4].str();
code = match[5].str();
message = match[6].str();
} else if (std::regex_match(line, match, clang_line)) {
file = match[1].str();
row = positive_number(match[2].str());
severity = match[3].str();
code = match[4].str();
message = match[5].str();
} else if (std::regex_match(line, match, lua)) {
file = match[1].str();
row = positive_number(match[2].str());
severity = "error";
message = match[3].str();
} else
continue;
if (row == 0 || (has_column && column == 0))
continue;
if (severity == "fatal error")
severity = "error";
if (message.size() > max_message)
message.resize(max_message);
Json diagnostic{{"severity", severity},
{"phase", std::string(phase)},
{"message", message},
{"line", row}};
if (column > 0)
diagnostic["column"] = column;
if (!code.empty())
diagnostic["code"] = code;
if (auto relative = project_source(file, project_root); !relative.empty())
diagnostic["file"] = std::move(relative);
rows.push_back(std::move(diagnostic));
}
return rows;
}
} // namespace faset::editor
+36 -345
View File
@@ -1,4 +1,3 @@
#include <algorithm>
#include <atomic> #include <atomic>
#include <cctype> #include <cctype>
#include <chrono> #include <chrono>
@@ -7,12 +6,9 @@
#include <faset/assets/asset_data.hpp> #include <faset/assets/asset_data.hpp>
#include <faset/assets/asset_pipeline.hpp> #include <faset/assets/asset_pipeline.hpp>
#include <faset/authoring/schema.hpp> #include <faset/authoring/schema.hpp>
#include <faset/authoring/service.hpp>
#include <faset/core/hash.hpp> #include <faset/core/hash.hpp>
#include <faset/core/io.hpp> #include <faset/core/io.hpp>
#include <faset/core/process.hpp> #include <faset/core/process.hpp>
#include <faset/editor/build_cache.hpp>
#include <faset/editor/build_diagnostics.hpp>
#include <faset/editor/build_service.hpp> #include <faset/editor/build_service.hpp>
#include <faset/scripting/project.hpp> #include <faset/scripting/project.hpp>
#include <fstream> #include <fstream>
@@ -93,107 +89,11 @@ fs::path build_executable(const fs::path& build, const std::string& configuratio
} }
throw std::runtime_error("Build did not produce " + target); throw std::runtime_error("Build did not produce " + target);
} }
Json starter_scene(const std::string& name, int dimension, std::string_view language) {
const auto schemas = authoring::builtin_schemas();
auto scene = authoring::make_scene(name + " — Starter", dimension);
scene["simulation"] = authoring::default_simulation_settings();
auto add_component = [&](Json& entity, std::string type, Json fields) {
entity["components"].push_back({{"id", new_id()},
{"type", std::move(type)},
{"version", 1},
{"fields", std::move(fields)}});
};
auto ground = authoring::make_entity(schemas, "Ground");
auto& ground_pose = ground["components"][0]["fields"];
ground_pose["position"] = {0, -1, 0};
ground_pose["scale"] = dimension == 2 ? Json::array({12, 1, 1}) : Json::array({12, 1, 12});
if (dimension == 2) {
add_component(ground, "faset.sprite", {{"size", {1, 1}},
{"color", {0.20, 0.25, 0.31, 1}}});
} else {
add_component(ground, "faset.mesh", {{"asset", "builtin:cube"},
{"color", {0.20, 0.25, 0.31, 1}}});
}
auto ground_body = schemas.default_fields("faset.rigid_body_" + std::to_string(dimension) +
"d");
ground_body["body_type"] = "static";
add_component(ground, "faset.rigid_body_" + std::to_string(dimension) + "d",
std::move(ground_body));
scene["entities"].push_back(std::move(ground));
auto actor = authoring::make_entity(schemas, "Player");
actor["components"][0]["fields"]["position"] = {0, 1, 0};
if (dimension == 2) {
add_component(actor, "faset.sprite", {{"size", {0.8, 0.8}},
{"color", {0.25, 0.72, 0.85, 1}}});
} else {
actor["components"][0]["fields"]["scale"] = {0.8, 0.8, 0.8};
add_component(actor, "faset.mesh", {{"asset", "builtin:cube"},
{"color", {0.25, 0.72, 0.85, 1}}});
}
add_component(actor, "faset.rigid_body_" + std::to_string(dimension) + "d",
schemas.default_fields("faset.rigid_body_" + std::to_string(dimension) + "d"));
add_component(actor, language == "lua" ? "starter.player" : "gameplay.character",
{{"speed", 4.0}, {"jump_speed", 5.0}});
scene["entities"].push_back(std::move(actor));
if (dimension == 3) {
auto camera = authoring::make_entity(schemas, "Camera");
camera["components"][0]["fields"]["position"] = {0, 2.5, 8};
camera["components"][0]["fields"]["rotation"] = {-0.18, 0, 0};
add_component(camera, "faset.camera", schemas.default_fields("faset.camera"));
scene["entities"].push_back(std::move(camera));
auto sun = authoring::make_entity(schemas, "Sun");
add_component(sun, "faset.light", schemas.default_fields("faset.light"));
scene["entities"].push_back(std::move(sun));
}
// Validate every built-in component now; the behavior component is validated by
// SchemaExporter from the selected C++/Lua module during the first build.
auto builtins = scene;
for (auto& entity : builtins["entities"]) {
auto& components = entity["components"].get_ref<Json::array_t&>();
std::erase_if(components, [](const Json& component) {
return !component.at("type").get<std::string>().starts_with("faset.");
});
}
authoring::validate_scene(builtins, schemas);
return scene;
}
struct StarterCreateLock {
fs::path path;
explicit StarterCreateLock(const fs::path& root) : path(root / ".faset/starter-create.lock") {
if (!fs::create_directory(path))
throw std::runtime_error("Another project creation is in progress; remove a stale " +
path_to_utf8(path) + " only after closing that Editor");
}
~StarterCreateLock() {
std::error_code ignored;
fs::remove_all(path, ignored);
}
StarterCreateLock(const StarterCreateLock&) = delete;
StarterCreateLock& operator=(const StarterCreateLock&) = delete;
};
void require_new_starter_directory(const fs::path& root) {
// BuildService creates .faset/cache in its constructor. Any other content is
// user-owned, including a previous starter, and must be left intact.
for (const auto& entry : fs::directory_iterator(root)) {
if (entry.path().filename() != ".faset" || !entry.is_directory() ||
entry.is_symlink())
throw std::runtime_error("Create requires a new or empty project directory");
for (const auto& internal : fs::directory_iterator(entry.path())) {
if (internal.path().filename() == "starter-create.lock" &&
internal.is_directory() && !internal.is_symlink())
continue;
if (internal.path().filename() != "cache" || !internal.is_directory() ||
internal.is_symlink() || !fs::is_empty(internal.path()))
throw std::runtime_error("Create requires a new or empty project directory");
}
}
}
} // namespace } // namespace
Json JobStatus::json() const { Json JobStatus::json() const {
return {{"id", id}, {"kind", kind}, {"state", state}, return {{"id", id}, {"kind", kind}, {"state", state},
{"stage", stage}, {"progress", progress}, {"log", log}, {"stage", stage}, {"progress", progress}, {"log", log},
{"error", error}, {"diagnostics", diagnostics}, {"result", result}}; {"error", error}, {"result", result}};
} }
void write_cooked_scene(const fs::path& path, const Json& scene) { void write_cooked_scene(const fs::path& path, const Json& scene) {
validate_scene(scene); validate_scene(scene);
@@ -298,13 +198,6 @@ struct BuildService::Impl {
std::string run(Job& job, std::vector<std::string> arguments, const fs::path& cwd) { std::string run(Job& job, std::vector<std::string> arguments, const fs::path& cwd) {
if (job.cancelled) if (job.cancelled)
throw Cancelled{}; throw Cancelled{};
std::string phase;
std::size_t diagnostics_before;
{
std::lock_guard lock(job.mutex);
phase = job.status.stage;
diagnostics_before = job.status.diagnostics.size();
}
std::string description = "$"; std::string description = "$";
for (const auto& argument : arguments) for (const auto& argument : arguments)
description += " " + Json(argument).dump(); description += " " + Json(argument).dump();
@@ -312,35 +205,6 @@ struct BuildService::Impl {
log(job, description); log(job, description);
Process process({std::move(arguments), cwd, {}}); Process process({std::move(arguments), cwd, {}});
std::string output; std::string output;
std::string pending;
const auto collect_diagnostics = [&](std::string_view chunk, bool final) {
pending.append(chunk);
std::size_t newline;
while ((newline = pending.find('\n')) != std::string::npos) {
const auto line = pending.substr(0, newline + 1);
pending.erase(0, newline + 1);
const auto found = parse_build_diagnostics(line, phase, config.project_root);
if (found.empty())
continue;
std::lock_guard lock(job.mutex);
for (const auto& row : found) {
if (job.status.diagnostics.size() >= 200)
break;
job.status.diagnostics.push_back(row);
}
}
if (final && !pending.empty()) {
const auto found = parse_build_diagnostics(pending, phase, config.project_root);
std::lock_guard lock(job.mutex);
for (const auto& row : found) {
if (job.status.diagnostics.size() >= 200)
break;
job.status.diagnostics.push_back(row);
}
pending.clear();
} else if (pending.size() > 8192)
pending.erase(0, pending.size() - 8192);
};
while (true) { while (true) {
if (job.cancelled) { if (job.cancelled) {
process.cancel(); process.cancel();
@@ -350,29 +214,14 @@ struct BuildService::Impl {
} }
auto poll = process.poll(); auto poll = process.poll();
log(job, poll.output); log(job, poll.output);
collect_diagnostics(poll.output, !poll.running);
output += poll.output; output += poll.output;
if (output.size() > max_log_bytes) if (output.size() > max_log_bytes)
output.erase(0, output.size() - max_log_bytes); output.erase(0, output.size() - max_log_bytes);
if (!poll.running) { if (!poll.running) {
if (poll.exit_code.value_or(1) != 0) { if (poll.exit_code.value_or(1) != 0)
std::lock_guard lock(job.mutex);
bool has_error = false;
for (std::size_t index = diagnostics_before;
index < job.status.diagnostics.size(); ++index)
if (job.status.diagnostics[index].value("severity", "") == "error")
has_error = true;
if (!has_error && job.status.diagnostics.size() < 200)
job.status.diagnostics.push_back(
{{"severity", "error"},
{"phase", phase},
{"message", "Process exited with code " +
std::to_string(poll.exit_code.value_or(1)) +
"; see job log"}});
throw std::runtime_error("Process exited with code " + throw std::runtime_error("Process exited with code " +
std::to_string(poll.exit_code.value_or(1)) + std::to_string(poll.exit_code.value_or(1)) +
"; see job log"); "; see job log");
}
return output; return output;
} }
std::this_thread::sleep_for(std::chrono::milliseconds(15)); std::this_thread::sleep_for(std::chrono::milliseconds(15));
@@ -391,23 +240,19 @@ struct BuildService::Impl {
} }
} }
Json build(Job& job, bool exporting = false) { Json build(Job& job, bool exporting = false) {
const auto started = std::chrono::steady_clock::now();
const auto milliseconds = [](auto from, auto to) {
return std::chrono::duration_cast<std::chrono::milliseconds>(to - from).count();
};
const auto& configuration = exporting ? config.export_configuration : config.configuration; const auto& configuration = exporting ? config.export_configuration : config.configuration;
const auto native_directory = config.build_directory / configuration; const auto native_directory = config.build_directory / configuration;
checkpoint(job, "Configuring gameplay", .05); checkpoint(job, "Configuring gameplay", .05);
job.lua = scripting::loadLuaProject(config.project_root); job.lua = scripting::loadLuaProject(config.project_root);
const auto inputs = capture_build_inputs(config, job.lua); const auto cpp = config.project_root / "Scripts" / "Gameplay.cpp";
const auto staged_scripts = stage_gameplay_sources(config, inputs, job.lua, job.status.id); const auto hpp = config.project_root / "Scripts" / "Gameplay.hpp";
const auto cpp = staged_scripts / "Gameplay.cpp";
const auto hpp = staged_scripts / "Gameplay.hpp";
const bool has_cpp = fs::is_regular_file(cpp), has_hpp = fs::is_regular_file(hpp); const bool has_cpp = fs::is_regular_file(cpp), has_hpp = fs::is_regular_file(hpp);
if (has_cpp != has_hpp || (!has_cpp && !job.lua.enabled())) if (has_cpp != has_hpp || (!has_cpp && !job.lua.enabled()))
throw std::runtime_error("Project requires Scripts/Gameplay.cpp and Gameplay.hpp, " throw std::runtime_error("Project requires Scripts/Gameplay.cpp and Gameplay.hpp, "
"or Lua entry scripts declared in project.faset.json"); "or Lua entry scripts declared in project.faset.json");
ensure_native_toolchain_stamp(config, native_directory, inputs); const auto cpp_source = has_cpp ? read_text(cpp) : std::string{};
const auto hpp_source = has_hpp ? read_text(hpp) : std::string{};
fs::create_directories(native_directory);
std::vector<std::string> arguments = {config.cmake, std::vector<std::string> arguments = {config.cmake,
"-S", "-S",
path_to_utf8(config.engine_root), path_to_utf8(config.engine_root),
@@ -422,100 +267,37 @@ struct BuildService::Impl {
"-DFASET_BUILD_RUNTIME=ON", "-DFASET_BUILD_RUNTIME=ON",
"-DFASET_BUILD_ASSETS=ON", "-DFASET_BUILD_ASSETS=ON",
"-DFASET_GAMEPLAY_SOURCE_DIR=" + "-DFASET_GAMEPLAY_SOURCE_DIR=" +
path_to_utf8(staged_scripts)}; path_to_utf8(config.project_root / "Scripts")};
if (!configured_cmake_value(config, "CMAKE_C_COMPILER")) { bool compiler_overridden = false;
for (const auto& arg : config.configure_arguments)
if (arg.starts_with("-DCMAKE_CXX_COMPILER="))
compiler_overridden = true;
if (!compiler_overridden) {
#ifdef _WIN32 #ifdef _WIN32
auto compiler = find_executable("clang-cl"); auto compiler = find_executable("clang-cl");
arguments.push_back("-DCMAKE_C_COMPILER=" + path_to_utf8(compiler)); arguments.push_back("-DCMAKE_C_COMPILER=" + path_to_utf8(compiler));
arguments.push_back("-DCMAKE_CXX_COMPILER=" + path_to_utf8(compiler));
#else #else
arguments.push_back("-DCMAKE_C_COMPILER=" + path_to_utf8(find_executable("clang"))); arguments.push_back("-DCMAKE_C_COMPILER=" + path_to_utf8(find_executable("clang")));
#endif
}
if (!configured_cmake_value(config, "CMAKE_CXX_COMPILER")) {
#ifdef _WIN32
arguments.push_back("-DCMAKE_CXX_COMPILER=" +
path_to_utf8(find_executable("clang-cl")));
#else
arguments.push_back("-DCMAKE_CXX_COMPILER=" + path_to_utf8(find_executable("clang++"))); arguments.push_back("-DCMAKE_CXX_COMPILER=" + path_to_utf8(find_executable("clang++")));
#endif #endif
} }
for (const auto& argument : config.configure_arguments) { arguments.insert(arguments.end(), config.configure_arguments.begin(),
const auto equal = argument.find('='); config.configure_arguments.end());
const auto colon = argument.find(':', 2);
const auto key = argument.substr(2, colon < equal ? colon - 2 : equal - 2);
if (key == "CMAKE_C_COMPILER" || key == "CMAKE_CXX_COMPILER" ||
key == "SLANGC_EXECUTABLE" || key == "CMAKE_TOOLCHAIN_FILE") {
const auto value = path_from_utf8(argument.substr(equal + 1));
if (value.has_parent_path() && value.is_relative()) {
arguments.push_back(argument.substr(0, equal + 1) +
path_to_utf8((config.project_root / value).lexically_normal()));
continue;
}
}
arguments.push_back(argument);
}
arguments.push_back("-DCMAKE_BUILD_TYPE=" + configuration); arguments.push_back("-DCMAKE_BUILD_TYPE=" + configuration);
// Project declarations, not a stale cache or a user-supplied override, determine // Project declarations, not a stale cache or a user-supplied override, determine
// whether the packaged game has a Lua VM linked into it. // whether the packaged game has a Lua VM linked into it.
arguments.push_back(std::string("-DFASET_ENABLE_LUA=") + arguments.push_back(std::string("-DFASET_ENABLE_LUA=") +
(job.lua.enabled() ? "ON" : "OFF")); (job.lua.enabled() ? "ON" : "OFF"));
run(job, std::move(arguments), config.project_root); run(job, std::move(arguments), config.project_root);
const auto configured = std::chrono::steady_clock::now();
checkpoint(job, "Compiling and linking Player", .25); checkpoint(job, "Compiling and linking Player", .25);
run(job, run(job,
{config.cmake, "--build", path_to_utf8(native_directory), "--config", configuration, {config.cmake, "--build", path_to_utf8(native_directory), "--config", configuration,
"--parallel", "4", "--target", "faset_player", "faset_schema_exporter"}, "--parallel", "4", "--target", "faset_player", "faset_schema_exporter"},
config.project_root); config.project_root);
const auto compiled = std::chrono::steady_clock::now(); checkpoint(job, "Exporting gameplay schema", .58);
auto player = build_executable(native_directory, configuration, "faset_player"); auto player = build_executable(native_directory, configuration, "faset_player");
auto exporter = build_executable(native_directory, configuration, "faset_schema_exporter"); auto exporter = build_executable(native_directory, configuration, "faset_schema_exporter");
const auto package_key =
build_package_key(inputs, native_directory, configuration, player, exporter);
const auto source_unchanged = [&] {
if (gameplay_source_hash(config.project_root,
scripting::loadLuaProject(config.project_root)) !=
inputs.source_hash)
throw std::runtime_error("Gameplay sources changed during the build; build again");
};
const auto result_for = [&](const std::string& id, const std::string& fingerprint,
bool reused) -> Json {
const auto directory = config.cache_root / "builds" / id;
const auto finished = std::chrono::steady_clock::now();
return {{"generation", id},
{"directory", path_to_utf8(directory)},
{"build_directory", path_to_utf8(native_directory)},
{"configuration", configuration},
{"player", path_to_utf8(directory / ("faset_player" + executable_suffix()))},
{"schema", path_to_utf8(directory / "schema.json")},
{"lua_enabled", job.lua.enabled()},
{"lua_fingerprint", job.lua.fingerprint},
{"fingerprint", fingerprint},
{"schema_cache_hit", reused},
{"generation_reused", reused},
{"phase_times_ms",
{{"configure", milliseconds(started, configured)},
{"native_build", milliseconds(configured, compiled)},
{"schema_package", reused ? 0 : milliseconds(compiled, finished)},
{"total", milliseconds(started, finished)}}}};
};
const auto pointer_file = config.cache_root / "last_build.json";
if (fs::is_regular_file(pointer_file))
try {
const auto pointer = read_json(pointer_file);
const auto id = pointer.at("generation").get<std::string>();
const auto candidate =
project_path(config.cache_root, fs::path("builds") / id);
if (validate_build_generation(candidate, package_key)) {
source_unchanged();
checkpoint(job, "Reusing verified build generation", .68);
const auto manifest = read_json(candidate / "manifest.json");
log(job, "Verified schema/package cache hit: " + id + "\n");
return result_for(id, manifest.at("fingerprint").get<std::string>(), true);
}
} catch (const std::exception&) {
// A bad pointer or old/corrupt generation is a cache miss.
}
checkpoint(job, "Exporting gameplay schema", .58);
const auto staging = config.cache_root / "builds" / (".staging-" + job.status.id); const auto staging = config.cache_root / "builds" / (".staging-" + job.status.id);
const auto generation = config.cache_root / "builds" / job.status.id; const auto generation = config.cache_root / "builds" / job.status.id;
fs::create_directories(staging); fs::create_directories(staging);
@@ -540,7 +322,10 @@ struct BuildService::Impl {
// Validate the complete metadata before publishing either the schema or // Validate the complete metadata before publishing either the schema or
// its Player generation. Session uses this same authoring contract. // its Player generation. Session uses this same authoring contract.
(void)authoring::gameplay_schemas(schema); (void)authoring::gameplay_schemas(schema);
const auto fingerprint = package_key; std::string fingerprint = sha256_file(player) + sha256_file(exporter) +
read_text(native_directory / "CMakeCache.txt");
fingerprint += cpp_source + hpp_source + job.lua.fingerprint;
fingerprint = sha256(fingerprint);
schema["build_fingerprint"] = fingerprint; schema["build_fingerprint"] = fingerprint;
if (job.lua.enabled()) if (job.lua.enabled())
schema["lua_fingerprint"] = job.lua.fingerprint; schema["lua_fingerprint"] = job.lua.fingerprint;
@@ -556,39 +341,36 @@ struct BuildService::Impl {
"gpuHzbMain.reflection.json", "gpuPostCullMain.reflection.json"}) "gpuHzbMain.reflection.json", "gpuPostCullMain.reflection.json"})
copy_required_file(native_directory / "shaders" / file, staging / "shaders" / file); copy_required_file(native_directory / "shaders" / file, staging / "shaders" / file);
copy_runtime_libraries(job, player, staging, native_directory, configuration); copy_runtime_libraries(job, player, staging, native_directory, configuration);
Json files = Json::array();
for (const auto& entry : fs::recursive_directory_iterator(staging)) {
if (entry.is_symlink())
throw std::runtime_error("Build generation contains a symlink");
if (entry.is_regular_file())
files.push_back(
{{"path", generic_path_to_utf8(entry.path().lexically_relative(staging))},
{"sha256", sha256_file(entry.path())},
{"size", entry.file_size()}});
}
Json manifest{{"format", "faset.build"}, Json manifest{{"format", "faset.build"},
{"version", 2}, {"version", 1},
{"id", job.status.id}, {"id", job.status.id},
{"fingerprint", fingerprint}, {"fingerprint", fingerprint},
{"package_key", package_key},
{"configuration", configuration}, {"configuration", configuration},
{"lua_enabled", job.lua.enabled()}, {"lua_enabled", job.lua.enabled()},
{"lua_fingerprint", job.lua.fingerprint}, {"lua_fingerprint", job.lua.fingerprint},
{"player", "faset_player" + executable_suffix()}, {"player", "faset_player" + executable_suffix()},
{"schema", "schema.json"}, {"schema", "schema.json"}};
{"files", files}};
atomic_write_json(staging / "manifest.json", manifest); atomic_write_json(staging / "manifest.json", manifest);
checkpoint(job, "Publishing build generation", .68); checkpoint(job, "Publishing build generation", .68);
if (job.lua.enabled() && if (job.lua.enabled() &&
scripting::loadLuaProject(staging).fingerprint != job.lua.fingerprint) scripting::loadLuaProject(staging).fingerprint != job.lua.fingerprint)
throw std::runtime_error("Lua build snapshot changed during schema export"); throw std::runtime_error("Lua build snapshot changed during schema export");
source_unchanged(); if (scripting::loadLuaProject(config.project_root).fingerprint != job.lua.fingerprint ||
if (!validate_build_generation(staging, package_key)) has_cpp != fs::is_regular_file(cpp) || has_hpp != fs::is_regular_file(hpp) ||
throw std::runtime_error("Build generation failed final integrity validation"); (has_cpp && (read_text(cpp) != cpp_source || read_text(hpp) != hpp_source)))
throw std::runtime_error("Gameplay sources changed during the build; build again");
fs::rename(staging, generation); fs::rename(staging, generation);
atomic_write_json(config.cache_root / "last_build.json", atomic_write_json(config.cache_root / "last_build.json",
{{"generation", job.status.id}, {"fingerprint", fingerprint}}); {{"generation", job.status.id}, {"fingerprint", fingerprint}});
return result_for(job.status.id, fingerprint, false); return {{"generation", job.status.id},
{"directory", path_to_utf8(generation)},
{"build_directory", path_to_utf8(native_directory)},
{"configuration", configuration},
{"player", path_to_utf8(generation / ("faset_player" + executable_suffix()))},
{"schema", path_to_utf8(generation / "schema.json")},
{"lua_enabled", job.lua.enabled()},
{"lua_fingerprint", job.lua.fingerprint},
{"fingerprint", fingerprint}};
} catch (...) { } catch (...) {
std::error_code error; std::error_code error;
fs::remove_all(staging, error); fs::remove_all(staging, error);
@@ -1003,97 +785,6 @@ void BuildService::scaffold(const std::string& name, int dimension) {
if (!fs::exists(c.project_root / ".gitignore")) if (!fs::exists(c.project_root / ".gitignore"))
atomic_write(c.project_root / ".gitignore", ".faset/\nExports/\n"); atomic_write(c.project_root / ".gitignore", ".faset/\nExports/\n");
} }
void BuildService::scaffold(const std::string& name, int dimension,
std::string_view language) {
if (name.empty() || (dimension != 2 && dimension != 3) ||
(language != "cpp" && language != "lua"))
throw std::invalid_argument("Starter requires a name, 2D/3D and cpp/lua language");
const auto& c = impl_->config;
StarterCreateLock lock(c.project_root);
require_new_starter_directory(c.project_root);
const auto scene = starter_scene(name, dimension, language);
const auto source = c.engine_root / "tools/project_templates" /
(language == "lua" ? "lua-main.lua" : "Gameplay.cpp");
const auto module = read_text(source);
const auto header = language == "cpp"
? read_text(c.engine_root / "tools/project_templates/Gameplay.hpp")
: std::string();
const auto lua_annotations = language == "lua"
? read_text(c.engine_root / "tools/lua/faset.lua")
: std::string();
const auto lua_config = language == "lua"
? read_json(c.engine_root / "tools/lua/luarc.json")
: Json();
const auto lua_scripts_config =
language == "lua" ? read_json(c.engine_root / "tools/lua/luarc-scripts.json") : Json();
const auto stage = lock.path / "staged";
fs::create_directories(stage / "Scripts");
fs::create_directories(stage / "Scenes");
fs::create_directories(stage / "Assets");
if (language == "cpp") {
atomic_write(stage / "Scripts/Gameplay.cpp", module);
atomic_write(stage / "Scripts/Gameplay.hpp", header);
} else {
atomic_write(stage / "Scripts/main.lua", module);
atomic_write(stage / ".faset/lua/faset.lua", lua_annotations);
atomic_write_json(stage / ".luarc.json", lua_config);
atomic_write_json(stage / "Scripts/.luarc.json", lua_scripts_config);
}
atomic_write_json(stage / "Scenes/main.scene.json", scene);
Json project = {{"format", "faset.project"},
{"version", 1},
{"id", new_id()},
{"name", name},
{"dimension", dimension},
{"start_scene", "Scenes/main.scene.json"}};
if (language == "lua")
project["scripting"] = {{"lua", {{"scripts", Json::array({"Scripts/main.lua"})}}}};
atomic_write_json(stage / "project.faset.json", project);
atomic_write(stage / ".gitignore",
".faset/*\n!.faset/lua/\n.faset/lua/*\n!.faset/lua/faset.lua\nExports/\n");
std::vector<std::pair<fs::path, std::string>> created;
auto publish = [&](const fs::path& relative) {
const auto source = stage / relative;
const auto target = c.project_root / relative;
fs::create_directories(target.parent_path());
if (!fs::copy_file(source, target, fs::copy_options::none))
throw std::runtime_error("Starter destination appeared during creation: " +
path_to_utf8(target));
created.emplace_back(target, sha256_file(source));
};
try {
publish(".gitignore");
if (language == "cpp") {
publish("Scripts/Gameplay.cpp");
publish("Scripts/Gameplay.hpp");
} else {
publish("Scripts/main.lua");
publish(".faset/lua/faset.lua");
publish(".luarc.json");
publish("Scripts/.luarc.json");
}
publish("Scenes/main.scene.json");
fs::create_directory(c.project_root / "Assets");
// The manifest is the final commit marker: an interrupted create has no
// valid project record and never replaces an existing project file.
publish("project.faset.json");
} catch (...) {
for (auto it = created.rbegin(); it != created.rend(); ++it) {
try {
std::error_code ignored;
if (fs::is_regular_file(it->first, ignored) &&
sha256_file(it->first) == it->second)
fs::remove(it->first, ignored);
} catch (...) { /* Preserve the original create failure. */
}
}
for (const auto& relative : {"Assets", "Scenes", "Scripts", ".faset/lua"}) {
std::error_code ignored;
fs::remove(c.project_root / relative, ignored); // Empty directories only.
}
throw;
}
}
std::string BuildService::start_build() { std::string BuildService::start_build() {
return impl_->enqueue("build"); return impl_->enqueue("build");
} }
+57 -100
View File
@@ -2,7 +2,6 @@
#include <chrono> #include <chrono>
#include <faset/core/hash.hpp> #include <faset/core/hash.hpp>
#include <faset/core/io.hpp> #include <faset/core/io.hpp>
#include <faset/editor/build_cache.hpp>
#include <faset/editor/session.hpp> #include <faset/editor/session.hpp>
#include <faset/scripting/project.hpp> #include <faset/scripting/project.hpp>
@@ -113,10 +112,6 @@ void Session::scaffold(const std::string& name, int dimension) {
builds_.scaffold(name, dimension); builds_.scaffold(name, dimension);
log("Created project: " + name); log("Created project: " + name);
} }
void Session::scaffold(const std::string& name, int dimension, std::string_view language) {
builds_.scaffold(name, dimension, language);
log("Created " + std::string(language) + " starter project: " + name);
}
Json Session::assets_list() const { Json Session::assets_list() const {
Json list = Json::array(); Json list = Json::array();
const auto directory = assets_.cache_root() / "assets"; const auto directory = assets_.cache_root() / "assets";
@@ -137,7 +132,20 @@ Json Session::assets_list() const {
} }
std::string Session::source_signature() const { std::string Session::source_signature() const {
const auto lua = scripting::loadLuaProject(config_.project_root); const auto lua = scripting::loadLuaProject(config_.project_root);
return gameplay_source_hash(config_.project_root, lua); const auto directory = config_.project_root / "Scripts";
std::vector<std::filesystem::path> files;
if (std::filesystem::exists(directory))
for (const auto& file : std::filesystem::recursive_directory_iterator(directory))
if (file.is_regular_file() && (!lua.enabled() || file.path().extension() != ".lua"))
files.push_back(file.path());
std::sort(files.begin(), files.end());
std::string contents;
for (const auto& file : files)
contents += generic_path_to_utf8(file.lexically_relative(directory)) + ":" +
sha256_file(file) + "\n";
if (lua.enabled())
contents += "lua:" + lua.fingerprint + "\n";
return sha256(contents);
} }
Json Session::schema_status() const { Json Session::schema_status() const {
try { try {
@@ -506,102 +514,51 @@ void Session::register_commands() {
{"configuration_created", create_configuration}, {"configuration_created", create_configuration},
{"scripts_configuration_created", create_scripts_configuration}}; {"scripts_configuration_created", create_scripts_configuration}};
}); });
const auto open_source = [this](const Json& args, bool lua_only) -> Json { commands_.add(
const auto path_error = lua_only ? "lua.source" : "source.path"; "faset_script_open",
const auto editor_error = lua_only ? "lua.editor" : "source.editor"; "Open a project Lua source in an external editor, never as an executable. Optional editor "
const auto requested = path_from_utf8(args.at("path").get<std::string>()); "is an argv array (default: project editor.script_editor, then zed). Exact {file} and "
require(!requested.empty() && !requested.is_absolute(), path_error, "{project} arguments are replaced; no shell expansion is performed.",
"Choose an existing source inside Scripts"); schema({{"path", text}, {"editor", {{"type", "array"}, {"items", text}, {"minItems", 1}}}},
for (const auto& part : requested) {"path"}),
require(part != "..", path_error, "Source path cannot traverse outside Scripts"); [&](const Json& args) {
std::filesystem::path file; const auto file = project_path(config_.project_root,
try { path_from_utf8(args.at("path").get<std::string>()));
file = project_path(config_.project_root, requested); require(file.extension() == ".lua" && std::filesystem::is_regular_file(file),
} catch (const std::exception&) { "lua.source", "Select an existing .lua file inside the project");
throw Error(path_error, "Source path must stay inside project Scripts"); Json command = Json::array({"zed", "{file}"});
} const auto settings = project();
const auto project_root = std::filesystem::weakly_canonical(config_.project_root); if (settings.contains("editor") && settings.at("editor").is_object() &&
const auto relative = file.lexically_relative(project_root); settings.at("editor").contains("script_editor"))
const auto name = generic_path_to_utf8(relative); command = settings.at("editor").at("script_editor");
const auto extension = path_to_utf8(file.extension()); if (args.contains("editor"))
const bool supported = extension == ".cpp" || extension == ".hpp" || command = args.at("editor");
extension == ".h" || extension == ".cc" || require(command.is_array() && !command.empty(), "lua.editor",
extension == ".cxx" || extension == ".lua"; "Configure editor.script_editor as a nonempty executable/argument array");
require(name.starts_with("Scripts/") && supported && std::vector<std::string> arguments;
(!lua_only || extension == ".lua") && bool has_file = false;
std::filesystem::is_regular_file(file), for (const auto& part : command) {
path_error, "Choose an existing source file inside Scripts"); require(part.is_string(), "lua.editor", "Editor arguments must be strings");
const auto line = args.value("line", 1); auto argument = part.get<std::string>();
const auto column = args.value("column", 1); require(argument.find('\0') == std::string::npos, "lua.editor",
require(line > 0 && column > 0, path_error, "Source line and column must be positive"); "Editor arguments cannot contain NUL");
Json command = Json::array({"zed", "{file}:{line}:{column}"}); if (argument == "{file}") {
const auto settings = project(); require(!arguments.empty(), "lua.editor", "The first argument is the editor");
if (settings.contains("editor") && settings.at("editor").is_object() && argument = path_to_utf8(file);
settings.at("editor").contains("script_editor")) has_file = true;
command = settings.at("editor").at("script_editor"); } else if (argument == "{project}") {
if (args.contains("editor")) require(!arguments.empty(), "lua.editor", "The first argument is the editor");
command = args.at("editor"); argument = path_to_utf8(std::filesystem::absolute(config_.project_root));
require(command.is_array() && !command.empty(), editor_error, }
"Configure editor.script_editor as a nonempty executable/argument array"); arguments.push_back(std::move(argument));
auto replace = [](std::string& target, std::string_view token,
const std::string& replacement) {
bool found = false;
std::size_t position = 0;
while ((position = target.find(token, position)) != std::string::npos) {
target.replace(position, token.size(), replacement);
position += replacement.size();
found = true;
} }
return found; require(!arguments.front().empty(), "lua.editor", "Choose an editor executable");
}; if (!has_file)
std::vector<std::string> arguments; arguments.push_back(path_to_utf8(file));
bool has_file = false;
for (const auto& part : command) {
require(part.is_string(), editor_error, "Editor arguments must be strings");
auto argument = part.get<std::string>();
require(argument.find('\0') == std::string::npos, editor_error,
"Editor arguments cannot contain NUL");
if (arguments.empty())
require(!argument.empty() && argument.find('{') == std::string::npos,
editor_error, "The first argument must be an editor executable");
else {
has_file |= replace(argument, "{file}", path_to_utf8(file));
replace(argument, "{line}", std::to_string(line));
replace(argument, "{column}", std::to_string(column));
replace(argument, "{project}", path_to_utf8(project_root));
}
arguments.push_back(std::move(argument));
}
if (!has_file)
arguments.push_back(path_to_utf8(file));
try {
launch_detached(arguments, config_.project_root); launch_detached(arguments, config_.project_root);
} catch (const std::exception& error) { log("Opened Lua source in external editor: " + args.at("path").get<std::string>());
throw Error(editor_error, "Could not launch external source editor", return Json{{"opened", args.at("path")}};
{{"reason", error.what()}}); });
}
log("Opened source in external editor: " + name);
return {{"opened", name}, {"path", name}, {"line", line}, {"column", column}};
};
const auto source_schema = schema(
{{"path", text},
{"line", {{"type", "integer"}, {"minimum", 1}}},
{"column", {{"type", "integer"}, {"minimum", 1}}},
{"editor", {{"type", "array"}, {"items", text}, {"minItems", 1}}}},
{"path"});
commands_.add("faset_source_open",
"Open a C++ or Lua source under Scripts at a one-based line and column. "
"The editor is an argv array; {file}, {line}, {column} and {project} are "
"substituted without a shell.",
source_schema, [open_source](const Json& args) {
return open_source(args, false);
});
commands_.add("faset_script_open",
"Open a project Lua source in an external editor. This compatibility command "
"retains Lua-only validation and uses the same safe source launcher.",
source_schema, [open_source](const Json& args) {
return open_source(args, true);
});
commands_.add("faset_export", commands_.add("faset_export",
"Build, validate and export a resolved authoring snapshot to a project-relative " "Build, validate and export a resolved authoring snapshot to a project-relative "
"output directory. Returns a job ID.", "output directory. Returns a job ID.",
+129 -5
View File
@@ -61,6 +61,16 @@ render::Vec3 direction(const render::Mat4& m, render::Vec3 p) {
return {m[0] * p[0] + m[4] * p[1] + m[8] * p[2], m[1] * p[0] + m[5] * p[1] + m[9] * p[2], return {m[0] * p[0] + m[4] * p[1] + m[8] * p[2], m[1] * p[0] + m[5] * p[1] + m[9] * p[2],
m[2] * p[0] + m[6] * p[1] + m[10] * p[2]}; m[2] * p[0] + m[6] * p[1] + m[10] * p[2]};
} }
render::Vec3 normalized(render::Vec3 value, const std::string& entityId,
std::string_view field) {
const auto length = std::hypot(value[0], value[1], value[2]);
if (!std::isfinite(length) || length < 1e-6f)
throw std::invalid_argument("Light on entity " + entityId + " has invalid " +
std::string(field));
for (auto& axis : value)
axis /= length;
return value;
}
std::pair<std::string, std::string> reference(const std::string& ref) { std::pair<std::string, std::string> reference(const std::string& ref) {
const auto hash = ref.find('#'); const auto hash = ref.find('#');
return {ref.substr(0, hash), hash == std::string::npos ? std::string{} : ref.substr(hash + 1)}; return {ref.substr(0, hash), hash == std::string::npos ? std::string{} : ref.substr(hash + 1)};
@@ -263,8 +273,12 @@ render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSetting
const auto id = entity.at("id").get<std::string>(); const auto id = entity.at("id").get<std::string>();
if (!byId.emplace(id, &entity).second) if (!byId.emplace(id, &entity).second)
throw std::invalid_argument("Duplicate scene ID"); throw std::invalid_argument("Duplicate scene ID");
if (!entity.contains("components") && entity.contains("light"))
out.authored_lights_present = true;
for (const auto& component : entity.value("components", Json::array())) { for (const auto& component : entity.value("components", Json::array())) {
const auto type = component.at("type").get<std::string>(); const auto type = component.at("type").get<std::string>();
if (type == "faset.light")
out.authored_lights_present = true;
if (component.value("version", 1) != 1 && if (component.value("version", 1) != 1 &&
(type == "faset.transform" || type == "faset.sprite" || type == "faset.mesh" || (type == "faset.transform" || type == "faset.sprite" || type == "faset.mesh" ||
type == "faset.camera" || type == "faset.light")) type == "faset.camera" || type == "faset.light"))
@@ -301,8 +315,15 @@ render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSetting
render::Vec3 cameraUp{0, 1, 0}; render::Vec3 cameraUp{0, 1, 0};
bool foundCamera = false; bool foundCamera = false;
std::vector<std::pair<int, render::Sprite>> sprites; std::vector<std::pair<int, render::Sprite>> sprites;
std::vector<render::SunLight> directionalLights;
for (const auto& entity : entities) { for (const auto& entity : entities) {
const auto model = world(world, entity); const auto id = entity.at("id").get<std::string>();
render::Mat4 model;
try {
model = world(world, entity);
} catch (const std::exception& error) {
throw std::invalid_argument("Entity " + id + " transform: " + error.what());
}
if (auto fields = properties(entity, "camera"); if (auto fields = properties(entity, "camera");
!fields.is_null() && !camera.overrideSceneCamera && !foundCamera) { !fields.is_null() && !camera.overrideSceneCamera && !foundCamera) {
camera.eye = point(model, {0, 0, 0}); camera.eye = point(model, {0, 0, 0});
@@ -314,8 +335,97 @@ render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSetting
foundCamera = true; foundCamera = true;
camera_id = entity.at("id").get<std::string>(); camera_id = entity.at("id").get<std::string>();
} }
if (auto fields = properties(entity, "light"); !fields.is_null()) if (auto fields = properties(entity, "light"); !fields.is_null()) {
out.light_direction = direction(model, {-0.5f, -1, -0.3f}); auto invalid = [&](std::string_view field) -> void {
throw std::invalid_argument("Light on entity " + id + " has invalid " +
std::string(field));
};
for (const auto entry : model)
if (!std::isfinite(entry))
invalid("transform");
auto number = [&](const char* field, float fallback) {
if (!fields.contains(field))
return fallback;
const auto& value = fields.at(field);
if (!value.is_number())
invalid(field);
const auto decimal = value.get<double>();
if (!std::isfinite(decimal) ||
std::abs(decimal) > std::numeric_limits<float>::max())
invalid(field);
return static_cast<float>(decimal);
};
auto boolean = [&](const char* field, bool fallback) {
if (!fields.contains(field))
return fallback;
if (!fields.at(field).is_boolean())
invalid(field);
return fields.at(field).get<bool>();
};
const auto enabled = boolean("enabled", true);
if (enabled) {
if (fields.contains("kind") && !fields.at("kind").is_string())
invalid("kind");
const auto kind = fields.value("kind", std::string("directional"));
if (kind != "directional" && kind != "point" && kind != "spot")
invalid("kind");
render::Color color;
try {
color = vec<4>(fields, "color", {1, 1, 1, 1});
} catch (const std::exception&) {
invalid("color");
}
for (const auto channel : color)
if (channel < 0)
invalid("color");
const auto intensity = number("intensity", 1);
if (intensity < 0)
invalid("intensity");
const auto castsShadow = boolean("casts_shadow", true);
const auto stableId = id;
if (kind == "directional") {
directionalLights.push_back({stableId,
normalized(direction(model, {-0.5f, -1, -0.3f}), id,
"direction"),
color, intensity, castsShadow});
} else {
render::LocalLight local;
local.kind = kind == "point" ? render::LocalLight::Kind::Point
: render::LocalLight::Kind::Spot;
local.stable_id = stableId;
local.position = point(model, {0, 0, 0});
for (const auto coordinate : local.position)
if (!std::isfinite(coordinate))
invalid("position");
if (local.kind == render::LocalLight::Kind::Spot)
local.direction = normalized(direction(model, {0, 0, -1}), id,
"direction");
local.color = color;
local.intensity = intensity;
local.range = number("range", 10);
if (local.range <= 0)
invalid("range");
local.inner_angle = number("inner_angle", 0.35f);
local.outer_angle = number("outer_angle", 0.7f);
if (local.kind == render::LocalLight::Kind::Spot &&
(local.inner_angle < 0 || local.inner_angle > local.outer_angle ||
local.outer_angle <= 0 ||
local.outer_angle >= std::numbers::pi_v<float> / 2))
invalid("inner_angle/outer_angle");
local.casts_shadow = castsShadow;
if (fields.contains("shadow_priority")) {
if (!fields.at("shadow_priority").is_number_integer())
invalid("shadow_priority");
const auto priority = fields.at("shadow_priority").get<double>();
if (priority < std::numeric_limits<int>::min() ||
priority > std::numeric_limits<int>::max())
invalid("shadow_priority");
local.shadow_priority = fields.at("shadow_priority").get<int>();
}
out.local_lights.push_back(std::move(local));
}
}
}
if (auto fields = properties(entity, "sprite"); !fields.is_null()) { if (auto fields = properties(entity, "sprite"); !fields.is_null()) {
render::Sprite sprite; render::Sprite sprite;
sprite.layer = fields.value("layer", 0); sprite.layer = fields.value("layer", 0);
@@ -372,6 +482,18 @@ render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSetting
[](const auto& a, const auto& b) { return a.first < b.first; }); [](const auto& a, const auto& b) { return a.first < b.first; });
for (auto& pair : sprites) for (auto& pair : sprites)
out.sprites.push_back(std::move(pair.second)); out.sprites.push_back(std::move(pair.second));
std::sort(directionalLights.begin(), directionalLights.end(),
[](const auto& a, const auto& b) { return a.stable_id < b.stable_id; });
std::sort(out.local_lights.begin(), out.local_lights.end(),
[](const auto& a, const auto& b) { return a.stable_id < b.stable_id; });
if (!directionalLights.empty()) {
out.sun = directionalLights.front();
out.light_direction = out.sun->direction;
if (directionalLights.size() > 1)
impl_->messages.push_back("warning: multiple enabled directional lights; using " +
out.sun->stable_id + " and ignoring " +
std::to_string(directionalLights.size() - 1) + " others");
}
if (dimension == 2) { if (dimension == 2) {
const float height = camera.orthographicHeight; const float height = camera.orthographicHeight;
if (!std::isfinite(height) || height <= 0) if (!std::isfinite(height) || height <= 0)
@@ -408,8 +530,10 @@ render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSetting
out.projection = render::perspective( out.projection = render::perspective(
camera.verticalFovDegrees * std::numbers::pi_v<float> / 180, aspect, camera.verticalFovDegrees * std::numbers::pi_v<float> / 180, aspect,
camera.nearPlane, camera.farPlane); camera.nearPlane, camera.farPlane);
out.view_projection = render::multiply( const auto view = render::look_at(camera.eye, camera.target, cameraUp);
out.projection, render::look_at(camera.eye, camera.target, cameraUp)); out.view_projection = render::multiply(out.projection, view);
out.camera_frustum = render::CameraFrustum{view, out.projection, camera.nearPlane,
camera.farPlane, true};
} }
std::sort(impl_->messages.begin(), impl_->messages.end()); std::sort(impl_->messages.begin(), impl_->messages.end());
impl_->messages.erase(std::unique(impl_->messages.begin(), impl_->messages.end()), impl_->messages.erase(std::unique(impl_->messages.begin(), impl_->messages.end()),
+392
View File
@@ -0,0 +1,392 @@
#include <faset/render/lighting.hpp>
#include <algorithm>
#include <array>
#include <cmath>
#include <limits>
#include <numbers>
#include <stdexcept>
#include <unordered_set>
namespace faset::render {
namespace {
Vec3 add(Vec3 a, Vec3 b) { return {a[0] + b[0], a[1] + b[1], a[2] + b[2]}; }
Vec3 subtract(Vec3 a, Vec3 b) { return {a[0] - b[0], a[1] - b[1], a[2] - b[2]}; }
Vec3 scale(Vec3 a, float factor) { return {a[0] * factor, a[1] * factor, a[2] * factor}; }
float dot(Vec3 a, Vec3 b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; }
float length(Vec3 a) { return std::sqrt(dot(a, a)); }
Vec3 unit(Vec3 a) {
const float magnitude = length(a);
if (!std::isfinite(magnitude) || magnitude < 1e-6f)
throw std::invalid_argument("Shadow light direction must be finite and nonzero");
return scale(a, 1.f / magnitude);
}
Vec3 project(const Mat4& matrix, Vec3 value) {
return {matrix[0] * value[0] + matrix[4] * value[1] + matrix[8] * value[2] + matrix[12],
matrix[1] * value[0] + matrix[5] * value[1] + matrix[9] * value[2] + matrix[13],
matrix[2] * value[0] + matrix[6] * value[1] + matrix[10] * value[2] + matrix[14]};
}
std::array<float, 4> clip(const Mat4& matrix, Vec3 value) {
return {matrix[0] * value[0] + matrix[4] * value[1] + matrix[8] * value[2] + matrix[12],
matrix[1] * value[0] + matrix[5] * value[1] + matrix[9] * value[2] + matrix[13],
matrix[2] * value[0] + matrix[6] * value[1] + matrix[10] * value[2] + matrix[14],
matrix[3] * value[0] + matrix[7] * value[1] + matrix[11] * value[2] + matrix[15]};
}
std::array<Vec3, 8> corners(const Bounds& bounds) {
std::array<Vec3, 8> result{};
for (unsigned i = 0; i < 8; ++i)
result[i] = {i & 1 ? bounds.max[0] : bounds.min[0],
i & 2 ? bounds.max[1] : bounds.min[1],
i & 4 ? bounds.max[2] : bounds.min[2]};
return result;
}
Vec3 camera_to_world(const Mat4& view, Vec3 camera) {
// CameraFrustum::view is an unscaled, orthonormal look_at matrix.
return {view[0] * (camera[0] - view[12]) + view[1] * (camera[1] - view[13]) +
view[2] * (camera[2] - view[14]),
view[4] * (camera[0] - view[12]) + view[5] * (camera[1] - view[13]) +
view[6] * (camera[2] - view[14]),
view[8] * (camera[0] - view[12]) + view[9] * (camera[1] - view[13]) +
view[10] * (camera[2] - view[14])};
}
std::array<Vec3, 8> frustum_slice(const CameraFrustum& camera, float near_distance,
float far_distance) {
std::array<Vec3, 8> result{};
for (unsigned i = 0; i < 8; ++i) {
const float distance = i & 4 ? far_distance : near_distance;
const float x = i & 1 ? 1.f : -1.f;
const float y = i & 2 ? 1.f : -1.f;
Vec3 local{};
if (camera.perspective)
local = {x * distance / camera.projection[0],
y * distance / camera.projection[5], -distance};
else
local = {(x - camera.projection[12]) / camera.projection[0],
(y - camera.projection[13]) / camera.projection[5], -distance};
result[i] = camera_to_world(camera.view, local);
}
return result;
}
bool overlaps_xy(const Bounds& bounds, const Mat4& light_view, float left, float right,
float bottom, float top) {
float min_x = std::numeric_limits<float>::infinity();
float max_x = -min_x, min_y = min_x, max_y = -min_x;
for (const auto point : corners(bounds)) {
const auto light = project(light_view, point);
min_x = std::min(min_x, light[0]);
max_x = std::max(max_x, light[0]);
min_y = std::min(min_y, light[1]);
max_y = std::max(max_y, light[1]);
}
return max_x >= left && min_x <= right && max_y >= bottom && min_y <= top;
}
bool intersects_frustum(const Bounds& bounds, const Mat4& view_projection) {
std::array<unsigned, 7> rejected{};
for (const auto point : corners(bounds)) {
const auto p = clip(view_projection, point);
if (!std::all_of(p.begin(), p.end(), [](float value) { return std::isfinite(value); }))
return true; // Invalid projection fails open so no caster is lost silently.
rejected[0] += p[0] < -p[3];
rejected[1] += p[0] > p[3];
rejected[2] += p[1] < -p[3];
rejected[3] += p[1] > p[3];
rejected[4] += p[2] < 0;
rejected[5] += p[2] > p[3];
rejected[6] += p[3] <= 0;
}
return std::none_of(rejected.begin(), rejected.end(), [](unsigned count) { return count == 8; });
}
void tile(ShadowView& view, std::uint32_t atlas_size, std::uint32_t tiles_across,
std::uint32_t index) {
constexpr std::uint32_t guard = 2;
const auto size = atlas_size / tiles_across;
view.tile_index = index;
view.tile_origin_x = (index % tiles_across) * size;
view.tile_origin_y = (index / tiles_across) * size;
view.tile_size = size;
view.usable_size = size - 2 * guard;
const auto reciprocal = 1.f / float(atlas_size);
view.atlas_scale_offset = {float(view.usable_size) * reciprocal,
float(view.usable_size) * reciprocal,
float(view.tile_origin_x + guard) * reciprocal,
float(view.tile_origin_y + guard) * reciprocal};
view.guarded_clamp = {(float(view.tile_origin_x + guard) + 1.5f) * reciprocal,
(float(view.tile_origin_y + guard) + 1.5f) * reciprocal,
(float(view.tile_origin_x + size - guard) - 1.5f) * reciprocal,
(float(view.tile_origin_y + size - guard) - 1.5f) * reciprocal};
}
std::vector<std::uint32_t> visible_casters(const Mat4& view_projection,
std::span<const ShadowCasterBounds> casters) {
std::vector<std::uint32_t> result;
for (const auto& caster : casters)
if (intersects_frustum(caster.world, view_projection))
result.push_back(caster.draw_index);
std::sort(result.begin(), result.end());
result.erase(std::unique(result.begin(), result.end()), result.end());
return result;
}
bool supported_atlas(std::uint32_t size, bool available) {
return available && (size == 2048 || size == 1024);
}
void validate_local(const LocalLight& light) {
const auto invalid = [&](const char* field) {
throw std::invalid_argument("Local light " + light.stable_id + " has invalid " + field);
};
if (light.stable_id.empty())
invalid("stable_id");
if (!std::all_of(light.position.begin(), light.position.end(),
[](float v) { return std::isfinite(v); }))
invalid("position");
if (!std::all_of(light.color.begin(), light.color.end(),
[](float v) { return std::isfinite(v) && v >= 0; }))
invalid("color");
if (!std::isfinite(light.intensity) || light.intensity < 0)
invalid("intensity");
if (!std::isfinite(light.range) || light.range <= 0)
invalid("range");
if (light.kind == LocalLight::Kind::Spot) {
if (!std::isfinite(light.inner_angle) || !std::isfinite(light.outer_angle) ||
light.inner_angle < 0 || light.inner_angle > light.outer_angle ||
light.outer_angle >= std::numbers::pi_v<float> / 2 || light.outer_angle <= 0)
invalid("inner_angle/outer_angle");
if (!std::all_of(light.direction.begin(), light.direction.end(),
[](float v) { return std::isfinite(v); }) ||
length(light.direction) < 1e-6f)
invalid("direction");
}
}
float projected_influence(const LocalLight& light, const Snapshot& frame) {
const auto distance = length(subtract(light.position, frame.eye));
const auto projection_scale =
std::max(std::abs(frame.projection[0]), std::abs(frame.projection[5]));
return light.range * projection_scale / std::max(distance, .1f);
}
ShadowView sun_view(const Snapshot& frame, const SunLight& sun,
std::span<const ShadowCasterBounds> casters, float split_near,
float split_far, std::uint32_t index, std::uint32_t atlas_size) {
ShadowView result;
result.kind = ShadowView::Kind::Sun;
result.light_id = sun.stable_id;
result.face_index = index;
result.split_near = split_near;
result.split_far = split_far;
tile(result, atlas_size, 2, index);
const auto direction = unit(sun.direction);
const auto up = std::abs(direction[1]) > .98f ? Vec3{0, 0, 1} : Vec3{0, 1, 0};
const auto light_origin_view = look_at({0, 0, 0}, direction, up);
if (!frame.camera_frustum) {
const auto light_eye = scale(direction, -30);
result.view_projection = multiply(orthographic(-20, 20, -20, 20, .1f, 80),
look_at(light_eye, {0, 0, 0}, up));
result.caster_indices = visible_casters(result.view_projection, casters);
return result;
}
const auto receivers = frustum_slice(*frame.camera_frustum, split_near, split_far);
Vec3 center{};
for (const auto corner : receivers)
center = add(center, scale(corner, 1.f / 8));
float radius{};
for (const auto corner : receivers)
radius = std::max(radius, length(subtract(corner, center)));
radius = std::max(.25f, std::ceil(radius * 16.f) / 16.f);
const auto center_light = project(light_origin_view, center);
const auto texel = (2 * radius) / float(result.usable_size);
result.snapped_center_x = std::round(center_light[0] / texel) * texel;
result.snapped_center_y = std::round(center_light[1] / texel) * texel;
const float left = result.snapped_center_x - radius;
const float right = result.snapped_center_x + radius;
const float bottom = result.snapped_center_y - radius;
const float top = result.snapped_center_y + radius;
float nearest_ray = std::numeric_limits<float>::infinity();
float furthest_ray = -nearest_ray;
for (const auto corner : receivers) {
const auto ray = dot(corner, direction);
nearest_ray = std::min(nearest_ray, ray);
furthest_ray = std::max(furthest_ray, ray);
}
for (const auto& caster : casters) {
if (!overlaps_xy(caster.world, light_origin_view, left, right, bottom, top))
continue;
result.caster_indices.push_back(caster.draw_index);
for (const auto corner : corners(caster.world)) {
const auto ray = dot(corner, direction);
nearest_ray = std::min(nearest_ray, ray);
furthest_ray = std::max(furthest_ray, ray);
}
}
std::sort(result.caster_indices.begin(), result.caster_indices.end());
result.caster_indices.erase(
std::unique(result.caster_indices.begin(), result.caster_indices.end()),
result.caster_indices.end());
const auto light_eye = scale(direction, nearest_ray - 1.f);
const auto view = look_at(light_eye, add(light_eye, direction), up);
const auto depth = std::max(2.f, furthest_ray - nearest_ray + 2.f);
result.view_projection = multiply(orthographic(left, right, bottom, top, .1f, depth),
view);
return result;
}
ShadowView local_view(const LocalLight& light, std::uint32_t face,
std::span<const ShadowCasterBounds> casters) {
ShadowView result;
result.kind = light.kind == LocalLight::Kind::Point ? ShadowView::Kind::Point
: ShadowView::Kind::Spot;
result.light_id = light.stable_id;
result.face_index = face;
static constexpr std::array<Vec3, 6> axes{{{1, 0, 0}, {-1, 0, 0}, {0, 1, 0},
{0, -1, 0}, {0, 0, 1}, {0, 0, -1}}};
static constexpr std::array<Vec3, 6> ups{{{0, -1, 0}, {0, -1, 0}, {0, 0, 1},
{0, 0, -1}, {0, -1, 0}, {0, -1, 0}}};
const auto direction = light.kind == LocalLight::Kind::Point ? axes.at(face)
: unit(light.direction);
const auto up = light.kind == LocalLight::Kind::Point ? ups.at(face)
: std::abs(direction[1]) > .98f ? Vec3{0, 0, 1} : Vec3{0, 1, 0};
const auto near_plane = std::max(.0001f, std::min(.05f, light.range * .1f));
const auto fov = light.kind == LocalLight::Kind::Point
? std::numbers::pi_v<float> / 2 : light.outer_angle * 2;
result.view_projection = multiply(
perspective(fov, 1, near_plane, light.range),
look_at(light.position, add(light.position, direction), up));
result.caster_indices = visible_casters(result.view_projection, casters);
return result;
}
} // namespace
ShadowPlan build_shadow_plan(const Snapshot& frame,
std::span<const ShadowCasterBounds> casters,
const ShadowBudget& budget) {
for (const auto& caster : casters)
for (int axis = 0; axis < 3; ++axis)
if (!std::isfinite(caster.world.min[axis]) ||
!std::isfinite(caster.world.max[axis]) ||
caster.world.min[axis] > caster.world.max[axis])
throw std::invalid_argument("Shadow caster world bounds must be finite and ordered");
ShadowPlan plan;
plan.sun_atlas_size = supported_atlas(budget.sun_atlas_size, budget.sun_atlas_available)
? budget.sun_atlas_size : 0;
plan.local_atlas_size = supported_atlas(budget.local_atlas_size, budget.local_atlas_available)
? budget.local_atlas_size : 0;
std::unordered_set<std::string> ids;
std::vector<std::pair<std::size_t, float>> ranked;
ranked.reserve(frame.local_lights.size());
for (std::size_t i = 0; i < frame.local_lights.size(); ++i) {
const auto& light = frame.local_lights[i];
validate_local(light); // Validate overflow records too, before truncation.
if (!ids.insert(light.stable_id).second)
throw std::invalid_argument("Duplicate local light stable_id: " + light.stable_id);
ranked.emplace_back(i, projected_influence(light, frame));
}
std::sort(ranked.begin(), ranked.end(), [&](const auto& a, const auto& b) {
const auto& left = frame.local_lights[a.first];
const auto& right = frame.local_lights[b.first];
if (left.shadow_priority != right.shadow_priority)
return left.shadow_priority > right.shadow_priority;
if (a.second != b.second)
return a.second > b.second;
return left.stable_id < right.stable_id;
});
const auto selected = std::min<std::size_t>(ranked.size(),
std::min(budget.max_local_lights, 128u));
plan.omitted_local_lights = static_cast<std::uint32_t>(ranked.size() - selected);
for (std::size_t i = 0; i < selected; ++i)
plan.submitted_local_indices.push_back(ranked[i].first);
std::optional<SunLight> sun = frame.sun;
if (!sun && !frame.authored_lights_present && frame.local_lights.empty())
sun = SunLight{"legacy-sun", frame.light_direction, {1, 1, 1, 1}, 1, true};
if (sun && sun->casts_shadow) {
plan.requested_sun_cascades = frame.camera_frustum
? std::min(4u, budget.max_sun_views) : 1u;
if (frame.camera_frustum &&
(frame.camera_frustum->near_plane <= 0 ||
frame.camera_frustum->far_plane <= frame.camera_frustum->near_plane ||
frame.camera_frustum->projection[0] == 0 ||
frame.camera_frustum->projection[5] == 0))
throw std::invalid_argument("Shadow camera frustum is invalid");
const float near_plane = frame.camera_frustum ? frame.camera_frustum->near_plane : .1f;
const float far_plane = frame.camera_frustum
? std::min(frame.camera_frustum->far_plane, budget.max_shadow_distance) : 80.f;
if (far_plane <= near_plane)
throw std::invalid_argument("Shadow distance does not reach the camera near plane");
float previous = near_plane;
for (std::uint32_t i = 0; i < plan.requested_sun_cascades; ++i) {
const auto ratio = float(i + 1) / float(plan.requested_sun_cascades);
const auto logarithmic = near_plane * std::pow(far_plane / near_plane, ratio);
const auto uniform = near_plane + (far_plane - near_plane) * ratio;
const auto split = i + 1 == plan.requested_sun_cascades
? far_plane : .5f * (logarithmic + uniform);
ShadowView view;
if (plan.sun_atlas_size)
view = sun_view(frame, *sun, casters, previous, split, i, plan.sun_atlas_size);
else {
view.kind = ShadowView::Kind::Sun;
view.light_id = sun->stable_id;
view.face_index = i;
view.split_near = previous;
view.split_far = split;
view.reason = ShadowDropReason::Unavailable;
}
if (plan.sun_atlas_size &&
view.caster_indices.size() <=
budget.max_caster_draws - std::min(plan.caster_draws, budget.max_caster_draws)) {
view.valid = true;
plan.caster_draws += static_cast<std::uint32_t>(view.caster_indices.size());
++plan.effective_sun_cascades;
} else {
if (plan.sun_atlas_size)
view.reason = ShadowDropReason::CasterBudget;
view.caster_indices.clear();
++plan.dropped_sun_views;
}
plan.sun_views.push_back(std::move(view));
previous = split;
}
}
for (const auto source : plan.submitted_local_indices) {
const auto& light = frame.local_lights[source];
LocalShadowAssignment assignment;
assignment.source_index = source;
assignment.first_view = static_cast<std::uint32_t>(plan.local_views.size());
const auto faces = light.kind == LocalLight::Kind::Point ? 6u : 1u;
if (!light.casts_shadow || light.intensity == 0) {
plan.local_assignments.push_back(assignment);
continue;
}
plan.local_faces_requested += faces;
if (!plan.local_atlas_size)
assignment.reason = ShadowDropReason::Unavailable;
else if (const auto capacity = std::min(budget.max_local_faces, 16u);
faces > capacity - std::min(plan.local_faces_used, capacity))
assignment.reason = ShadowDropReason::TileBudget;
else {
std::vector<ShadowView> group;
std::uint32_t group_draws{};
for (std::uint32_t face = 0; face < faces; ++face) {
auto view = local_view(light, face, casters);
tile(view, plan.local_atlas_size, 4,
plan.local_faces_used + face);
group_draws += static_cast<std::uint32_t>(view.caster_indices.size());
group.push_back(std::move(view));
}
if (group_draws > budget.max_caster_draws -
std::min(plan.caster_draws, budget.max_caster_draws))
assignment.reason = ShadowDropReason::CasterBudget;
else {
assignment.valid = true;
assignment.face_count = faces;
plan.local_faces_used += faces;
plan.caster_draws += group_draws;
for (auto& view : group) {
view.valid = true;
plan.local_views.push_back(std::move(view));
}
}
}
if (!assignment.valid) {
plan.dropped_local_faces += faces;
if (light.kind == LocalLight::Kind::Point)
plan.dropped_point_faces += 6;
}
plan.local_assignments.push_back(assignment);
}
return plan;
}
} // namespace faset::render
+190 -21
View File
@@ -6,14 +6,17 @@
#include <bit> #include <bit>
#include <chrono> #include <chrono>
#include <cmath> #include <cmath>
#include <cstddef>
#include <cstring> #include <cstring>
#include <faset/core/io.hpp> #include <faset/core/io.hpp>
#include <faset/render/render_graph.hpp> #include <faset/render/render_graph.hpp>
#include <faset/render/lighting.hpp>
#include <faset/render/renderer.hpp> #include <faset/render/renderer.hpp>
#include <faset/render/visibility.hpp> #include <faset/render/visibility.hpp>
#include <fstream> #include <fstream>
#include <iostream> #include <iostream>
#include <limits> #include <limits>
#include <numbers>
#include <optional> #include <optional>
#include <stdexcept> #include <stdexcept>
#include <unordered_map> #include <unordered_map>
@@ -72,6 +75,40 @@ struct ScenePush {
std::array<std::uint32_t, 4> draw_info; std::array<std::uint32_t, 4> draw_info;
}; };
static_assert(sizeof(ScenePush) == 112); static_assert(sizeof(ScenePush) == 112);
struct LightingHeaderGpu {
std::array<std::uint32_t, 4> counts{};
std::array<float, 4> sun_direction_intensity{};
std::array<float, 4> sun_color{};
std::array<float, 4> camera_forward_shadow_distance{};
std::array<float, 4> cascade_splits{};
};
struct LocalLightGpu {
std::array<float, 4> position_range{};
std::array<float, 4> direction_cos_outer{};
std::array<float, 4> color_intensity{};
std::array<float, 4> cone_type_shadow_view{};
std::array<float, 4> reserved{};
};
struct ShadowViewGpu {
Mat4 view_projection{identity};
std::array<float, 4> tile_scale_offset{};
std::array<float, 4> guarded_clamp{};
std::array<float, 4> bias_flags{};
};
static_assert(sizeof(LightingHeaderGpu) == 80 &&
offsetof(LightingHeaderGpu, sun_direction_intensity) == 16 &&
offsetof(LightingHeaderGpu, sun_color) == 32 &&
offsetof(LightingHeaderGpu, camera_forward_shadow_distance) == 48 &&
offsetof(LightingHeaderGpu, cascade_splits) == 64);
static_assert(sizeof(LocalLightGpu) == 80 &&
offsetof(LocalLightGpu, direction_cos_outer) == 16 &&
offsetof(LocalLightGpu, color_intensity) == 32 &&
offsetof(LocalLightGpu, cone_type_shadow_view) == 48 &&
offsetof(LocalLightGpu, reserved) == 64);
static_assert(sizeof(ShadowViewGpu) == 112 &&
offsetof(ShadowViewGpu, tile_scale_offset) == 64 &&
offsetof(ShadowViewGpu, guarded_clamp) == 80 &&
offsetof(ShadowViewGpu, bias_flags) == 96);
std::array<float, 4> point(const Mat4& m, std::array<float, 4> p) { std::array<float, 4> point(const Mat4& m, std::array<float, 4> p) {
std::array<float, 4> o{}; std::array<float, 4> o{};
for (int r = 0; r < 4; ++r) for (int r = 0; r < 4; ++r)
@@ -196,6 +233,7 @@ struct Renderer::Impl {
std::vector<VkImageLayout> swap_layouts; std::vector<VkImageLayout> swap_layouts;
Image color, depth, shadow; Image color, depth, shadow;
Buffer vertices, readback; Buffer vertices, readback;
Buffer lighting_header, lighting_locals, lighting_views;
SceneResources scene; SceneResources scene;
InstanceTracker instance_tracker; InstanceTracker instance_tracker;
std::unordered_map<std::string, std::size_t> previous_lods; std::unordered_map<std::string, std::size_t> previous_lods;
@@ -212,6 +250,9 @@ struct Renderer::Impl {
std::unordered_map<const Texture*, CachedOpacity> opacity_cache; std::unordered_map<const Texture*, CachedOpacity> opacity_cache;
VkDescriptorSetLayout descriptor_layout{}; VkDescriptorSetLayout descriptor_layout{};
VkDescriptorPool descriptor_pool{}; VkDescriptorPool descriptor_pool{};
VkDescriptorSetLayout lighting_layout{};
VkDescriptorPool lighting_pool{};
VkDescriptorSet lighting_set{};
VkSampler shadow_sampler{}, color_sampler{}; VkSampler shadow_sampler{}, color_sampler{};
VkPipelineLayout pipeline_layout{}; VkPipelineLayout pipeline_layout{};
VkPipeline pipeline{}, ui_pipeline{}, shadow_pipeline{}, sprite_pipeline{}; VkPipeline pipeline{}, ui_pipeline{}, shadow_pipeline{}, sprite_pipeline{};
@@ -316,6 +357,9 @@ struct Renderer::Impl {
destroy(scene.hzb[1]); destroy(scene.hzb[1]);
destroy(vertices); destroy(vertices);
destroy(readback); destroy(readback);
destroy(lighting_header);
destroy(lighting_locals);
destroy(lighting_views);
destroy(color); destroy(color);
destroy(depth); destroy(depth);
destroy(shadow); destroy(shadow);
@@ -333,8 +377,12 @@ struct Renderer::Impl {
vkDestroyPipelineLayout(device, pipeline_layout, nullptr); vkDestroyPipelineLayout(device, pipeline_layout, nullptr);
if (descriptor_pool) if (descriptor_pool)
vkDestroyDescriptorPool(device, descriptor_pool, nullptr); vkDestroyDescriptorPool(device, descriptor_pool, nullptr);
if (lighting_pool)
vkDestroyDescriptorPool(device, lighting_pool, nullptr);
if (descriptor_layout) if (descriptor_layout)
vkDestroyDescriptorSetLayout(device, descriptor_layout, nullptr); vkDestroyDescriptorSetLayout(device, descriptor_layout, nullptr);
if (lighting_layout)
vkDestroyDescriptorSetLayout(device, lighting_layout, nullptr);
if (shadow_sampler) if (shadow_sampler)
vkDestroySampler(device, shadow_sampler, nullptr); vkDestroySampler(device, shadow_sampler, nullptr);
if (color_sampler) if (color_sampler)
@@ -889,6 +937,31 @@ struct Renderer::Impl {
pi.pPoolSizes = sizes; pi.pPoolSizes = sizes;
check(vkCreateDescriptorPool(device, &pi, nullptr, &descriptor_pool), check(vkCreateDescriptorPool(device, &pi, nullptr, &descriptor_pool),
"Create descriptor pool"); "Create descriptor pool");
std::array<VkDescriptorSetLayoutBinding, 4> lighting_bindings{};
for (std::uint32_t i = 0; i < lighting_bindings.size(); ++i)
lighting_bindings[i] = {i,
i == 3 ? VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE
: VK_DESCRIPTOR_TYPE_STORAGE_BUFFER,
1, VK_SHADER_STAGE_FRAGMENT_BIT, nullptr};
li.bindingCount = static_cast<std::uint32_t>(lighting_bindings.size());
li.pBindings = lighting_bindings.data();
check(vkCreateDescriptorSetLayout(device, &li, nullptr, &lighting_layout),
"Create lighting descriptor layout");
VkDescriptorPoolSize lighting_sizes[] = {
{VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, 3}, {VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, 1}};
pi.flags = 0;
pi.maxSets = 1;
pi.poolSizeCount = 2;
pi.pPoolSizes = lighting_sizes;
check(vkCreateDescriptorPool(device, &pi, nullptr, &lighting_pool),
"Create lighting descriptor pool");
VkDescriptorSetAllocateInfo lighting_allocation{};
lighting_allocation.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO;
lighting_allocation.descriptorPool = lighting_pool;
lighting_allocation.descriptorSetCount = 1;
lighting_allocation.pSetLayouts = &lighting_layout;
check(vkAllocateDescriptorSets(device, &lighting_allocation, &lighting_set),
"Allocate lighting descriptors");
VkSamplerCreateInfo si{}; VkSamplerCreateInfo si{};
si.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO; si.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO;
si.magFilter = si.minFilter = VK_FILTER_NEAREST; si.magFilter = si.minFilter = VK_FILTER_NEAREST;
@@ -1009,8 +1082,9 @@ struct Renderer::Impl {
sizeof(Push)}; sizeof(Push)};
VkPipelineLayoutCreateInfo li{}; VkPipelineLayoutCreateInfo li{};
li.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO; li.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
li.setLayoutCount = 1; const std::array<VkDescriptorSetLayout, 2> set_layouts{descriptor_layout, lighting_layout};
li.pSetLayouts = &descriptor_layout; li.setLayoutCount = static_cast<std::uint32_t>(set_layouts.size());
li.pSetLayouts = set_layouts.data();
li.pushConstantRangeCount = 1; li.pushConstantRangeCount = 1;
li.pPushConstantRanges = &push; li.pPushConstantRanges = &push;
check(vkCreatePipelineLayout(device, &li, nullptr, &pipeline_layout), check(vkCreatePipelineLayout(device, &li, nullptr, &pipeline_layout),
@@ -1220,14 +1294,14 @@ struct Renderer::Impl {
layout.pBindings = hzb.data(); layout.pBindings = hzb.data();
check(vkCreateDescriptorSetLayout(device, &layout, nullptr, &scene.hzb_layout), check(vkCreateDescriptorSetLayout(device, &layout, nullptr, &scene.hzb_layout),
"Create HZB descriptor layout"); "Create HZB descriptor layout");
const std::array<VkDescriptorSetLayout, 2> scene_layouts{descriptor_layout, const std::array<VkDescriptorSetLayout, 3> scene_layouts{
scene.graphics_layout}; descriptor_layout, lighting_layout, scene.graphics_layout};
VkPushConstantRange graphics_push{VK_SHADER_STAGE_VERTEX_BIT | VkPushConstantRange graphics_push{VK_SHADER_STAGE_VERTEX_BIT |
VK_SHADER_STAGE_FRAGMENT_BIT, VK_SHADER_STAGE_FRAGMENT_BIT,
0, sizeof(ScenePush)}; 0, sizeof(ScenePush)};
VkPipelineLayoutCreateInfo pipeline_info{}; VkPipelineLayoutCreateInfo pipeline_info{};
pipeline_info.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO; pipeline_info.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
pipeline_info.setLayoutCount = 2; pipeline_info.setLayoutCount = static_cast<std::uint32_t>(scene_layouts.size());
pipeline_info.pSetLayouts = scene_layouts.data(); pipeline_info.pSetLayouts = scene_layouts.data();
pipeline_info.pushConstantRangeCount = 1; pipeline_info.pushConstantRangeCount = 1;
pipeline_info.pPushConstantRanges = &graphics_push; pipeline_info.pPushConstantRanges = &graphics_push;
@@ -1533,6 +1607,29 @@ struct Renderer::Impl {
VkBufferUsageFlags usage = 0) { VkBufferUsageFlags usage = 0) {
upload_scene_buffer(buffer, values.data(), values.size() * sizeof(T), usage); upload_scene_buffer(buffer, values.data(), values.size() * sizeof(T), usage);
} }
void update_lighting_descriptors() {
const std::array<VkDescriptorBufferInfo, 3> buffers{{
{lighting_header.handle, 0, lighting_header.size},
{lighting_locals.handle, 0, lighting_locals.size},
{lighting_views.handle, 0, lighting_views.size}}};
const VkDescriptorImageInfo atlas{VK_NULL_HANDLE, shadow.view,
VK_IMAGE_LAYOUT_DEPTH_READ_ONLY_OPTIMAL};
std::array<VkWriteDescriptorSet, 4> writes{};
for (std::uint32_t i = 0; i < writes.size(); ++i) {
writes[i].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
writes[i].dstSet = lighting_set;
writes[i].dstBinding = i;
writes[i].descriptorCount = 1;
writes[i].descriptorType = i == 3 ? VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE
: VK_DESCRIPTOR_TYPE_STORAGE_BUFFER;
if (i == 3)
writes[i].pImageInfo = &atlas;
else
writes[i].pBufferInfo = &buffers[i];
}
vkUpdateDescriptorSets(device, static_cast<std::uint32_t>(writes.size()),
writes.data(), 0, nullptr);
}
void update_scene_descriptors(bool occlusion) { void update_scene_descriptors(bool occlusion) {
auto write_buffers = [&](VkDescriptorSet set, std::span<const Buffer* const> buffers, auto write_buffers = [&](VkDescriptorSet set, std::span<const Buffer* const> buffers,
std::uint32_t first_binding) { std::uint32_t first_binding) {
@@ -1625,6 +1722,7 @@ struct Renderer::Impl {
void render(const Snapshot& snapshot) { void render(const Snapshot& snapshot) {
auto start = std::chrono::steady_clock::now(); auto start = std::chrono::steady_clock::now();
statistics.draw_calls = statistics.culled_meshes = statistics.gpu_label_count = 0; statistics.draw_calls = statistics.culled_meshes = statistics.gpu_label_count = 0;
statistics.submitted_local_lights = statistics.omitted_local_lights = 0;
statistics.gpu_bins = statistics.gpu_visible_instances = statistics.gpu_bins = statistics.gpu_visible_instances =
statistics.gpu_frustum_rejected = statistics.gpu_occlusion_deferred = statistics.gpu_frustum_rejected = statistics.gpu_occlusion_deferred =
statistics.gpu_post_visible = 0; statistics.gpu_post_visible = 0;
@@ -1715,6 +1813,8 @@ struct Renderer::Impl {
}; };
std::vector<SelectedDraw> selected_draws; std::vector<SelectedDraw> selected_draws;
selected_draws.reserve(snapshot.draws.size()); selected_draws.reserve(snapshot.draws.size());
std::vector<ShadowCasterBounds> shadow_casters;
shadow_casters.reserve(snapshot.draws.size());
struct BuildingBin { struct BuildingBin {
const Mesh* mesh{}; const Mesh* mesh{};
const Texture* texture{}; const Texture* texture{};
@@ -1724,10 +1824,17 @@ struct Renderer::Impl {
std::vector<BuildingBin> building_bins; std::vector<BuildingBin> building_bins;
std::unordered_map<const Mesh*, std::pair<std::uint32_t, std::uint32_t>> mesh_ranges; std::unordered_map<const Mesh*, std::pair<std::uint32_t, std::uint32_t>> mesh_ranges;
std::unordered_map<std::string, std::size_t> current_lods; std::unordered_map<std::string, std::size_t> current_lods;
for (const auto& item : snapshot.draws) { for (std::size_t source_index = 0; source_index < snapshot.draws.size(); ++source_index) {
const auto& item = snapshot.draws[source_index];
if (!item.mesh || item.mesh->vertices.empty()) if (!item.mesh || item.mesh->vertices.empty())
continue; continue;
const auto source_bounds = world_bounds(item.mesh, item.model); const auto source_bounds = world_bounds(item.mesh, item.model);
if (item.cast_shadow) {
if (source_index > UINT32_MAX)
throw std::overflow_error("Shadow source draw index exceeds 32-bit capacity");
shadow_casters.push_back(
{source_bounds, static_cast<std::uint32_t>(source_index)});
}
std::vector<float> thresholds; std::vector<float> thresholds;
std::vector<std::uint8_t> available; std::vector<std::uint8_t> available;
std::shared_ptr<const Mesh> selected_mesh = item.mesh; std::shared_ptr<const Mesh> selected_mesh = item.mesh;
@@ -2017,15 +2124,73 @@ struct Renderer::Impl {
VK_BUFFER_USAGE_TRANSFER_DST_BIT); VK_BUFFER_USAGE_TRANSFER_DST_BIT);
update_scene_descriptors(occlusion); update_scene_descriptors(occlusion);
} }
Vec3 direction = snapshot.light_direction; std::optional<SunLight> sun = snapshot.sun;
if (!sun && !snapshot.authored_lights_present && snapshot.local_lights.empty())
sun = SunLight{"legacy-sun", snapshot.light_direction, {1, 1, 1, 1}, 1, true};
Vec3 direction = sun ? sun->direction : snapshot.light_direction;
float length = std::sqrt(direction[0] * direction[0] + direction[1] * direction[1] + float length = std::sqrt(direction[0] * direction[0] + direction[1] * direction[1] +
direction[2] * direction[2]); direction[2] * direction[2]);
if (length < 1e-5f) { if (!std::isfinite(length) || length < 1e-5f) {
if (sun && sun->stable_id != "legacy-sun")
throw std::invalid_argument("Authored sun direction must be finite and nonzero");
direction = {-.5f, -1, -.3f}; direction = {-.5f, -1, -.3f};
length = std::sqrt(1.34f); length = std::sqrt(1.34f);
} }
for (auto& v : direction) for (auto& v : direction)
v /= length; v /= length;
LightingHeaderGpu lighting{};
lighting.counts[1] = sun ? 1u : 0u;
lighting.counts[2] = sun && sun->casts_shadow ? 1u : 0u;
lighting.sun_direction_intensity = {direction[0], direction[1], direction[2],
sun ? sun->intensity : 0};
lighting.sun_color = sun ? sun->color : Color{0, 0, 0, 1};
if (sun && (!std::isfinite(sun->intensity) || sun->intensity < 0 ||
std::any_of(sun->color.begin(), sun->color.end(),
[](float v) { return !std::isfinite(v) || v < 0; })))
throw std::invalid_argument("Authored sun radiance must be finite and nonnegative");
lighting.camera_forward_shadow_distance = {0, 0, -1, 80};
if (snapshot.camera_frustum) {
const auto& view = snapshot.camera_frustum->view;
lighting.camera_forward_shadow_distance = {-view[2], -view[6], -view[10], 80};
}
const auto shadow_plan = build_shadow_plan(snapshot, shadow_casters);
statistics.omitted_local_lights = shadow_plan.omitted_local_lights;
std::vector<LocalLightGpu> gpu_lights;
gpu_lights.reserve(shadow_plan.submitted_local_indices.size());
for (const auto source : shadow_plan.submitted_local_indices) {
const auto& local = snapshot.local_lights[source];
auto spot_direction = local.direction;
float spot_length = std::hypot(spot_direction[0], spot_direction[1],
spot_direction[2]);
if (!std::isfinite(spot_length) || spot_length < 1e-6f) {
if (local.kind == LocalLight::Kind::Spot)
throw std::invalid_argument("Spotlight direction must be finite and nonzero");
spot_direction = {0, 0, -1};
spot_length = 1;
}
for (auto& axis : spot_direction)
axis /= spot_length;
LocalLightGpu gpu{};
gpu.position_range = {local.position[0], local.position[1], local.position[2],
local.range};
const bool spot = local.kind == LocalLight::Kind::Spot;
gpu.direction_cos_outer = {spot_direction[0], spot_direction[1], spot_direction[2],
spot ? std::cos(local.outer_angle) : 0.f};
gpu.color_intensity = {local.color[0], local.color[1], local.color[2],
local.intensity};
gpu.cone_type_shadow_view = {spot ? std::cos(local.inner_angle) : 1.f,
spot ? 1.f : 0.f, -1, 0};
gpu_lights.push_back(gpu);
}
lighting.counts[0] = static_cast<std::uint32_t>(gpu_lights.size());
statistics.submitted_local_lights = lighting.counts[0];
if (gpu_lights.empty())
gpu_lights.push_back({}); // Descriptors always point at a full initialized record.
const ShadowViewGpu empty_shadow_view{};
upload_scene_buffer(lighting_header, &lighting, sizeof(lighting), 0);
upload_scene_vector(lighting_locals, gpu_lights);
upload_scene_buffer(lighting_views, &empty_shadow_view, sizeof(empty_shadow_view), 0);
update_lighting_descriptors();
Vec3 light_eye{-direction[0] * 30, -direction[1] * 30, -direction[2] * 30}; Vec3 light_eye{-direction[0] * 30, -direction[1] * 30, -direction[2] * 30};
Vec3 light_up = std::abs(direction[1]) > .98f ? Vec3{0, 0, 1} : Vec3{0, 1, 0}; Vec3 light_up = std::abs(direction[1]) > .98f ? Vec3{0, 0, 1} : Vec3{0, 1, 0};
Push push{multiply(orthographic(-20, 20, -20, 20, .1f, 80), Push push{multiply(orthographic(-20, 20, -20, 20, .1f, 80),
@@ -2078,6 +2243,12 @@ struct Renderer::Impl {
vkCmdSetViewport(command, 0, 1, &viewport); vkCmdSetViewport(command, 0, 1, &viewport);
vkCmdSetScissor(command, 0, 1, &scissor); vkCmdSetScissor(command, 0, 1, &scissor);
}; };
auto bind_material = [&](VkDescriptorSet material) {
const std::array<VkDescriptorSet, 2> sets{material, lighting_set};
vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline_layout,
0, static_cast<std::uint32_t>(sets.size()), sets.data(),
0, nullptr);
};
auto draw_transparent = [&] { auto draw_transparent = [&] {
if (transparent_batches.empty()) if (transparent_batches.empty())
return; return;
@@ -2088,8 +2259,7 @@ struct Renderer::Impl {
0, sizeof(push), &push); 0, sizeof(push), &push);
for (auto batch : transparent_batches) { for (auto batch : transparent_batches) {
auto descriptor = textures.at(batch.texture).descriptor; auto descriptor = textures.at(batch.texture).descriptor;
vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS, bind_material(descriptor);
pipeline_layout, 0, 1, &descriptor, 0, nullptr);
vkCmdDraw(command, batch.count, 1, batch.first, 0); vkCmdDraw(command, batch.count, 1, batch.first, 0);
++statistics.draw_calls; ++statistics.draw_calls;
} }
@@ -2102,8 +2272,7 @@ struct Renderer::Impl {
sizeof(push), &push); sizeof(push), &push);
for (auto batch : sprite_batches) { for (auto batch : sprite_batches) {
auto descriptor = textures.at(batch.texture).descriptor; auto descriptor = textures.at(batch.texture).descriptor;
vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline_layout, bind_material(descriptor);
0, 1, &descriptor, 0, nullptr);
vkCmdDraw(command, batch.count, 1, batch.first, 0); vkCmdDraw(command, batch.count, 1, batch.first, 0);
++statistics.draw_calls; ++statistics.draw_calls;
} }
@@ -2125,8 +2294,7 @@ struct Renderer::Impl {
continue; continue;
vkCmdSetScissor(command, 0, 1, &scissor); vkCmdSetScissor(command, 0, 1, &scissor);
auto descriptor = textures.at(batch.texture).descriptor; auto descriptor = textures.at(batch.texture).descriptor;
vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline_layout, bind_material(descriptor);
0, 1, &descriptor, 0, nullptr);
vkCmdDraw(command, batch.count, 1, batch.first, 0); vkCmdDraw(command, batch.count, 1, batch.first, 0);
++statistics.draw_calls; ++statistics.draw_calls;
} }
@@ -2313,8 +2481,7 @@ struct Renderer::Impl {
vkCmdPushConstants(command, pipeline_layout, vkCmdPushConstants(command, pipeline_layout,
VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 0, VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 0,
sizeof(push), &push); sizeof(push), &push);
vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline_layout, 0, 1, bind_material(white_descriptor);
&white_descriptor, 0, nullptr);
if (gpu_active && !gpu_frame.bins.empty()) { if (gpu_active && !gpu_frame.bins.empty()) {
VkDeviceSize scene_offset{}; VkDeviceSize scene_offset{};
vkCmdBindVertexBuffers(command, 0, 1, &scene.vertices.handle, &scene_offset); vkCmdBindVertexBuffers(command, 0, 1, &scene.vertices.handle, &scene_offset);
@@ -2322,7 +2489,7 @@ struct Renderer::Impl {
scene.graphics_pipeline); scene.graphics_pipeline);
for (std::uint32_t bin = 0; bin < gpu_frame.bins.size(); ++bin) { for (std::uint32_t bin = 0; bin < gpu_frame.bins.size(); ++bin) {
auto descriptor = textures.at(gpu_frame.textures[bin]).descriptor; auto descriptor = textures.at(gpu_frame.textures[bin]).descriptor;
const std::array<VkDescriptorSet, 2> sets{descriptor, const std::array<VkDescriptorSet, 3> sets{descriptor, lighting_set,
scene.graphics_main}; scene.graphics_main};
vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS, vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS,
scene.graphics_pipeline_layout, 0, sets.size(), scene.graphics_pipeline_layout, 0, sets.size(),
@@ -2346,8 +2513,7 @@ struct Renderer::Impl {
} }
for (auto batch : scene_batches) { for (auto batch : scene_batches) {
auto descriptor = textures.at(batch.texture).descriptor; auto descriptor = textures.at(batch.texture).descriptor;
vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS, pipeline_layout, bind_material(descriptor);
0, 1, &descriptor, 0, nullptr);
vkCmdDraw(command, batch.count, 1, batch.first, 0); vkCmdDraw(command, batch.count, 1, batch.first, 0);
++statistics.draw_calls; ++statistics.draw_calls;
} }
@@ -2446,7 +2612,7 @@ struct Renderer::Impl {
scene.graphics_pipeline); scene.graphics_pipeline);
for (std::uint32_t bin = 0; bin < gpu_frame.bins.size(); ++bin) { for (std::uint32_t bin = 0; bin < gpu_frame.bins.size(); ++bin) {
auto descriptor = textures.at(gpu_frame.textures[bin]).descriptor; auto descriptor = textures.at(gpu_frame.textures[bin]).descriptor;
const std::array<VkDescriptorSet, 2> sets{descriptor, const std::array<VkDescriptorSet, 3> sets{descriptor, lighting_set,
scene.graphics_post}; scene.graphics_post};
vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS, vkCmdBindDescriptorSets(command, VK_PIPELINE_BIND_POINT_GRAPHICS,
scene.graphics_pipeline_layout, 0, scene.graphics_pipeline_layout, 0,
@@ -2618,7 +2784,10 @@ struct Renderer::Impl {
++statistics.frame; ++statistics.frame;
statistics.gpu_allocated_bytes = vertices.allocation_size + readback.allocation_size + statistics.gpu_allocated_bytes = vertices.allocation_size + readback.allocation_size +
color.allocation_size + depth.allocation_size + color.allocation_size + depth.allocation_size +
shadow.allocation_size; shadow.allocation_size +
lighting_header.allocation_size +
lighting_locals.allocation_size +
lighting_views.allocation_size;
statistics.texture_count = static_cast<std::uint32_t>(textures.size()); statistics.texture_count = static_cast<std::uint32_t>(textures.size());
for (const auto& [_, texture] : textures) for (const auto& [_, texture] : textures)
statistics.gpu_allocated_bytes += texture.image.allocation_size; statistics.gpu_allocated_bytes += texture.image.allocation_size;
+12 -4
View File
@@ -34,13 +34,21 @@ void validate_layout(const Json& layout, std::string_view entry) {
const bool fragment = entry == "fragmentMain"; const bool fragment = entry == "fragmentMain";
require(layout.at("stage") == (fragment ? "fragment" : "vertex"), "shader stage changed"); require(layout.at("stage") == (fragment ? "fragment" : "vertex"), "shader stage changed");
const auto& descriptors = layout.at("descriptors"); const auto& descriptors = layout.at("descriptors");
require(descriptors.is_array() && descriptors.size() == 4, "descriptor count changed"); require(descriptors.is_array() && descriptors.size() == 8, "descriptor count changed");
for (std::size_t i = 0; i < descriptors.size(); ++i) { for (std::size_t i = 0; i < descriptors.size(); ++i) {
const auto& binding = descriptors[i]; const auto& binding = descriptors[i];
require(binding.at("set") == 0 && binding.at("binding") == i && binding.at("count") == 1, const auto set = i < 4 ? 0 : 1;
const auto slot = i % 4;
require(binding.at("set") == set && binding.at("binding") == slot &&
binding.at("count") == 1,
"descriptor set, binding or array count changed"); "descriptor set, binding or array count changed");
require(binding.at("type") == (i % 2 ? "sampler" : "sampled_image_2d"), const auto* expected_type = set == 0 ? (slot % 2 ? "sampler" : "sampled_image_2d")
: slot == 3 ? "sampled_image_2d" : "storage_buffer";
require(binding.at("type") == expected_type,
"descriptor type changed"); "descriptor type changed");
if (set == 1 && slot < 3)
require(binding.at("element_stride") == (slot == 2 ? 112 : 80),
"lighting storage record stride changed");
require(fragment || !binding.at("used").get<bool>(), require(fragment || !binding.at("used").get<bool>(),
"vertex texture bindings are unsupported"); "vertex texture bindings are unsupported");
} }
@@ -98,7 +106,7 @@ void validate_gpu_layout(const Json& layout, std::string_view entry) {
const std::array<int, 3> graphics_strides{224, 4, 208}; const std::array<int, 3> graphics_strides{224, 4, 208};
for (std::size_t i = 0; i < expected_count; ++i) { for (std::size_t i = 0; i < expected_count; ++i) {
const auto& binding = descriptors[i]; const auto& binding = descriptors[i];
require(binding.at("set") == (graphics ? 1 : 0) && binding.at("binding") == i && require(binding.at("set") == (graphics ? 2 : 0) && binding.at("binding") == i &&
binding.at("count") == 1, binding.at("count") == 1,
"GPU descriptor set, binding or count changed"); "GPU descriptor set, binding or count changed");
const int stride = graphics ? graphics_strides[i] : hzb ? 0 : compute_strides[i]; const int stride = graphics ? graphics_strides[i] : hzb ? 0 : compute_strides[i];
+27
View File
@@ -2,6 +2,7 @@
#include <faset/authoring/templates.hpp> #include <faset/authoring/templates.hpp>
#include <faset/authoring/transforms.hpp> #include <faset/authoring/transforms.hpp>
#include <faset/core/io.hpp> #include <faset/core/io.hpp>
#include <cstdint>
#include <iostream> #include <iostream>
#define CHECK(x) \ #define CHECK(x) \
@@ -25,6 +26,32 @@ int main() {
const auto root = std::filesystem::temp_directory_path() / ("faset-authoring-" + new_id()); const auto root = std::filesystem::temp_directory_path() / ("faset-authoring-" + new_id());
try { try {
auto schemas = builtin_schemas(); auto schemas = builtin_schemas();
const auto light = schemas.schema("faset.light");
CHECK(light["version"] == 1);
const auto defaults = schemas.default_fields("faset.light");
CHECK(defaults["kind"] == "directional");
CHECK(defaults["enabled"] == true);
CHECK(defaults["range"] == 10.0);
CHECK(defaults["inner_angle"] < defaults["outer_angle"]);
CHECK(defaults["casts_shadow"] == true);
CHECK(defaults["shadow_priority"] == 0);
auto light_component = Json{{"type", "faset.light"}, {"version", 1}, {"fields", defaults}};
schemas.validate_component(light_component);
light_component["fields"]["kind"] = "area";
fails([&] { schemas.validate_component(light_component); }, "validation.enum");
light_component["fields"]["kind"] = "point";
light_component["fields"]["range"] = 0;
fails([&] { schemas.validate_component(light_component); }, "validation.minimum");
light_component["fields"]["range"] = 10;
light_component["fields"]["intensity"] = -1;
fails([&] { schemas.validate_component(light_component); }, "validation.minimum");
light_component["fields"] = {{"kind", "spot"}, {"inner_angle", 0.9}};
fails([&] { schemas.validate_component(light_component); }, "validation.light_cone");
light_component["fields"] = {{"kind", "spot"},
{"inner_angle", 0.2},
{"outer_angle", 0.5},
{"shadow_priority", std::int64_t{2147483648}}};
fails([&] { schemas.validate_component(light_component); }, "validation.maximum");
AuthoringService service(root, schemas); AuthoringService service(root, schemas);
auto created = service.create("Courtyard", 3); auto created = service.create("Courtyard", 3);
const std::string id = created["id"]; const std::string id = created["id"];
-196
View File
@@ -1,196 +0,0 @@
#include <faset/core/io.hpp>
#include <faset/editor/build_cache.hpp>
#include <faset/editor/build_service.hpp>
#include <faset/scripting/project.hpp>
#include <iostream>
using namespace faset;
namespace fs = std::filesystem;
namespace {
void check(bool condition, std::string_view message) {
if (!condition)
throw std::runtime_error(std::string(message));
}
void run(const fs::path& root) {
const auto project = root / "project";
const auto scripts = project / "Scripts";
atomic_write(scripts / "Gameplay.cpp", "#include \"Gameplay.hpp\"\n");
atomic_write(scripts / "Gameplay.hpp", "#pragma once\n");
atomic_write(scripts / "Extensions/Extra.hpp", "#define SPEED 1\n");
const auto compiler = root / "fake-compiler";
const auto cmake = root / "fake-cmake";
const auto slang = root / "fake-slangc";
atomic_write(compiler, "compiler-v1\n");
atomic_write(cmake, "cmake-v1\n");
atomic_write(slang, "slang-v1\n");
editor::BuildConfig config;
config.project_root = project;
config.engine_root = path_from_utf8(FASET_ENGINE_SOURCE);
config.build_directory = project / ".faset/build";
config.cmake = path_to_utf8(cmake);
config.configure_arguments = {
"-DCMAKE_C_COMPILER=" + path_to_utf8(compiler),
"-DCMAKE_CXX_COMPILER=" + path_to_utf8(compiler),
"-DSLANGC_EXECUTABLE=" + path_to_utf8(slang)};
const scripting::LuaProject none;
const auto initial = editor::capture_build_inputs(config, none);
atomic_write(scripts / "Extensions/Extra.hpp", "#define SPEED 2\n");
const auto changed_header = editor::capture_build_inputs(config, none);
check(initial.source_hash != changed_header.source_hash &&
initial.fingerprint() != changed_header.fingerprint(),
"Nested gameplay header changes the complete source snapshot");
atomic_write(scripts / "main.lua", "return {}\n");
atomic_write_json(project / "project.faset.json",
{{"format", "faset.project"},
{"version", 1},
{"scripting", {{"lua", {{"scripts", {"Scripts/main.lua"}}}}}}});
auto lua = scripting::loadLuaProject(project);
const auto with_lua = editor::capture_build_inputs(config, lua);
atomic_write(scripts / "other.lua", "return {}\n");
auto manifest = read_json(project / "project.faset.json");
manifest["scripting"]["lua"]["scripts"].push_back("Scripts/other.lua");
atomic_write_json(project / "project.faset.json", manifest);
lua = scripting::loadLuaProject(project);
const auto changed_declaration = editor::capture_build_inputs(config, lua);
check(with_lua.source_hash != changed_declaration.source_hash,
"Lua entry declaration changes the source snapshot");
const auto native = config.build_directory / "Debug";
editor::ensure_native_toolchain_stamp(config, native, changed_declaration);
atomic_write(native / "sentinel", "keep\n");
editor::ensure_native_toolchain_stamp(config, native, changed_declaration);
check(fs::exists(native / "sentinel"), "Unchanged toolchain preserves the native tree");
atomic_write(compiler, "compiler-v2\n");
const auto changed_tool = editor::capture_build_inputs(config, lua);
check(changed_tool.source_hash == changed_declaration.source_hash &&
changed_tool.toolchain_hash != changed_declaration.toolchain_hash,
"Changing compiler bytes at the same path changes toolchain identity");
editor::ensure_native_toolchain_stamp(config, native, changed_tool);
check(!fs::exists(native / "sentinel"),
"Changed toolchain invalidates only the generated native tree");
const auto user_directory = root / "important-user-data";
atomic_write(user_directory / "do-not-delete.txt", "valuable\n");
bool refused_unowned{};
try {
editor::ensure_native_toolchain_stamp(config, user_directory, changed_tool);
} catch (const std::exception&) {
refused_unowned = true;
}
check(refused_unowned && fs::is_regular_file(user_directory / "do-not-delete.txt"),
"Unowned native directory is never cleared");
std::error_code native_link_error;
fs::create_directory_symlink(user_directory, config.build_directory / "Release",
native_link_error);
if (!native_link_error) {
bool refused_link{};
try {
editor::ensure_native_toolchain_stamp(config, config.build_directory / "Release",
changed_tool);
} catch (const std::exception&) {
refused_link = true;
}
check(refused_link && fs::is_regular_file(user_directory / "do-not-delete.txt"),
"Symlinked native directory cannot redirect cleanup into user data");
}
const auto relative_tool = project / "tools/cmake-fixture";
atomic_write(relative_tool, "relative-cmake-v1\n");
auto relative_config = config;
relative_config.cmake = "tools/cmake-fixture";
const auto relative_before = editor::capture_build_inputs(relative_config, lua);
atomic_write(relative_tool, "relative-cmake-v2\n");
check(relative_before.toolchain_hash !=
editor::capture_build_inputs(relative_config, lua).toolchain_hash,
"Relative CMake executable resolves from the project working directory");
auto typed_config = config;
const auto typed_compiler = root / "typed-compiler";
atomic_write(typed_compiler, "typed-v1\n");
typed_config.configure_arguments = {
"-DCMAKE_C_COMPILER:FILEPATH=" + path_to_utf8(typed_compiler)};
const auto typed_before = editor::capture_build_inputs(typed_config, lua);
atomic_write(typed_compiler, "typed-v2\n");
check(typed_before.toolchain_hash !=
editor::capture_build_inputs(typed_config, lua).toolchain_hash,
"Typed CMake compiler overrides are included in toolchain identity");
auto malicious_config = config;
malicious_config.configure_arguments.push_back(
"-DFASET_GAMEPLAY_SOURCE_DIR:PATH=" + path_to_utf8(root / "outside"));
bool refused_source_override{};
try {
(void)editor::capture_build_inputs(malicious_config, lua);
} catch (const std::exception&) {
refused_source_override = true;
}
check(refused_source_override,
"Configure arguments cannot redirect gameplay compilation away from hashed Scripts");
const auto staged = editor::stage_gameplay_sources(config, changed_tool, lua,
"snapshot-fixture");
check(staged != scripts && fs::is_regular_file(staged / "Gameplay.cpp") &&
read_text(staged / "Extensions/Extra.hpp") == "#define SPEED 2\n",
"Native gameplay compilation uses a complete staged Scripts snapshot");
atomic_write(scripts / "Extensions/Extra.hpp", "#define SPEED 999\n");
check(read_text(staged / "Extensions/Extra.hpp") == "#define SPEED 2\n",
"A live edit cannot alter the source bytes of an in-flight native build");
atomic_write(scripts / "Extensions/Extra.hpp", "#define SPEED 2\n");
check(editor::stage_gameplay_sources(config, changed_tool, lua, "snapshot-again") ==
staged,
"Identical sources retain a stable native path for incremental Ninja builds");
atomic_write(native / "faset_player", "player-v1\n");
atomic_write(native / "faset_schema_exporter", "exporter-v1\n");
atomic_write(native / "CMakeCache.txt", "recipe-v1\n");
atomic_write(native / "shaders/vertexMain.spv", "shader-v1\n");
atomic_write(native / "faset_runtime.dll", "runtime-v1\n");
auto package_key = [&] (const editor::BuildInputs& inputs) {
return editor::build_package_key(inputs, native, "Debug", native / "faset_player",
native / "faset_schema_exporter");
};
const auto first_key = package_key(changed_tool);
check(first_key != package_key(changed_declaration) &&
first_key != package_key(with_lua),
"Toolchain and Lua source changes invalidate package identity");
auto option_config = config;
option_config.configure_arguments.push_back("-DFASET_TEST_OPTION=ON");
check(first_key != package_key(editor::capture_build_inputs(option_config, lua)),
"Configure option changes invalidate package identity");
atomic_write(native / "shaders/vertexMain.spv", "shader-v2\n");
check(first_key != package_key(changed_tool),
"Shader bytes invalidate package identity");
atomic_write(native / "shaders/vertexMain.spv", "shader-v1\n");
atomic_write(native / "faset_runtime.dll", "runtime-v2\n");
check(first_key != package_key(changed_tool),
"Runtime DLL bytes invalidate package identity");
fs::create_directories(root / "outside");
std::error_code link_error;
fs::create_directory_symlink(root / "outside", scripts / "linked", link_error);
if (!link_error) {
bool rejected = false;
try {
(void)editor::capture_build_inputs(config, lua);
} catch (const std::exception&) {
rejected = true;
}
check(rejected, "Source snapshot refuses a symlink escaping Scripts");
}
}
} // namespace
int main() {
const auto root = fs::temp_directory_path() / path_from_utf8("Faset build cache Café 世界 " + new_id());
try {
run(root);
fs::remove_all(root);
std::cout << "Source, toolchain and native package identity contracts passed\n";
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
fs::remove_all(root);
return 1;
}
}
-75
View File
@@ -1,75 +0,0 @@
#include <faset/editor/build_diagnostics.hpp>
#include <iostream>
#include <stdexcept>
using namespace faset;
namespace fs = std::filesystem;
namespace {
void check(bool condition, std::string_view message) {
if (!condition)
throw std::runtime_error(std::string(message));
}
void contracts() {
const auto project = fs::path("/project");
auto rows = editor::parse_build_diagnostics(
"/project/Scripts/Game.cpp:17:4: error: bad field\n", "compile", project);
check(rows.size() == 1 && rows[0].at("file") == "Scripts/Game.cpp" &&
rows[0].at("line") == 17 && rows[0].at("column") == 4 &&
rows[0].at("severity") == "error" && rows[0].at("phase") == "compile",
"Clang error has a project-relative source location");
rows = editor::parse_build_diagnostics(
"\x1b[31mScripts/player.lua:6: unexpected symbol near '='\x1b[0m\n",
"schema", project);
check(rows.size() == 1 && rows[0].at("file") == "Scripts/player.lua" &&
rows[0].at("line") == 6 && rows[0].at("severity") == "error" &&
rows[0].at("message") == "unexpected symbol near '='",
"Lua syntax error and ANSI stripping work");
const auto windows = fs::path(R"(C:\Café)");
rows = editor::parse_build_diagnostics(
R"(C:\Café\Scripts\Game.cpp(17,4): error C2143: syntax error)" "\n"
R"(C:\Café\Scripts\Game.cpp:19:2: warning: suspicious conversion)" "\n",
"compile", windows);
check(rows.size() == 2 && rows[0].at("file") == "Scripts/Game.cpp" &&
rows[0].at("code") == "C2143" && rows[0].at("line") == 17 &&
rows[1].at("severity") == "warning" && rows[1].at("line") == 19,
"Windows drive colon and Unicode directory remain intact");
rows = editor::parse_build_diagnostics(
"Scripts/Game.cpp:9:3: error: missing value\n"
" broken call\n"
" ^~~~~~\n"
"Scripts/Game.cpp:4:1: note: declared here\n",
"compile", project);
check(rows.size() == 2 && rows[0].at("severity") == "error" &&
rows[1].at("severity") == "note" && rows[1].at("file") == "Scripts/Game.cpp",
"Caret and source excerpts do not become duplicate diagnostics");
rows = editor::parse_build_diagnostics(
"/outside/Scripts/Game.cpp:4:2: error: external\n"
"../Scripts/Escape.cpp:5:2: error: escaped\n",
"compile", project);
check(rows.size() == 2 && !rows[0].contains("file") && !rows[1].contains("file"),
"External and traversing sources are never navigable");
rows = editor::parse_build_diagnostics(
"Scripts/../Scripts/Game.cpp:4:2: error: disguised traversal\n"
"Scripts/Game.cpp:4:0: error: invalid column\n",
"compile", project);
check(rows.size() == 1 && !rows[0].contains("file"),
"Traversal stays non-navigable and zero columns are rejected");
std::string noisy;
for (int index = 0; index < 300; ++index)
noisy += "Scripts/Game.cpp:2:1: warning: repeated\n";
check(editor::parse_build_diagnostics(noisy, "compile", project).size() <= 200,
"Structured diagnostic count is bounded");
}
} // namespace
int main() {
try {
contracts();
std::cout << "Build diagnostic parsing contracts passed\n";
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return 1;
}
}
+3 -101
View File
@@ -142,72 +142,11 @@ int test_main(int argc, char** argv) {
config.configure_arguments = {"-DCMAKE_CXX_COMPILER=fixture-does-not-compile"}; config.configure_arguments = {"-DCMAKE_CXX_COMPILER=fixture-does-not-compile"};
editor::BuildService builds(config); editor::BuildService builds(config);
builds.scaffold("Schema publication", 2); builds.scaffold("Schema publication", 2);
atomic_write(config.project_root / "Scripts/Extensions/BuildOnly.hpp",
"#define BUILD_ONLY 1\n");
authoring::AuthoringService authoring(config.project_root, authoring::builtin_schemas()); authoring::AuthoringService authoring(config.project_root, authoring::builtin_schemas());
const auto valid = manifest(); const auto valid = manifest();
atomic_write_json(config.project_root / "schema-fixture.json", valid); atomic_write_json(config.project_root / "schema-fixture.json", valid);
{ const auto first = builds.wait(builds.start_build());
auto compiler_config = config;
compiler_config.project_root = root / "c-only-compiler-project";
compiler_config.configure_arguments = {
"-DCMAKE_C_COMPILER:FILEPATH=fixture-c-only"};
editor::BuildService compiler_build(compiler_config);
compiler_build.scaffold("Compiler argument fixture", 2);
atomic_write_json(compiler_config.project_root / "schema-fixture.json", valid);
const auto compiler_result = compiler_build.wait(compiler_build.start_build());
check(compiler_result.state == "succeeded",
"C-only compiler override fixture builds: " + compiler_result.error);
const auto compiler_args =
read_json(compiler_config.project_root / "configure-fixture.json");
std::size_t c_overrides{};
bool cxx_default{};
for (const auto& arg : compiler_args) {
const auto text = arg.get<std::string>();
if (text.starts_with("-DCMAKE_C_COMPILER")) {
++c_overrides;
check(text == "-DCMAKE_C_COMPILER:FILEPATH=fixture-c-only",
"Typed C override is not replaced by a default compiler");
}
if (text.starts_with("-DCMAKE_CXX_COMPILER="))
cxx_default = true;
}
check(c_overrides == 1 && cxx_default,
"C and C++ compiler defaults are selected independently");
}
auto first = builds.wait(builds.start_build());
check(first.state == "succeeded", "Valid custom schema v2 publishes: " + first.error); check(first.state == "succeeded", "Valid custom schema v2 publishes: " + first.error);
fs::path compiled_scripts;
for (const auto& arg : read_json(config.project_root / "configure-fixture.json")) {
const auto text = arg.get<std::string>();
constexpr std::string_view prefix = "-DFASET_GAMEPLAY_SOURCE_DIR=";
if (text.starts_with(prefix))
compiled_scripts = path_from_utf8(text.substr(prefix.size()));
}
check(!compiled_scripts.empty() &&
compiled_scripts != config.project_root / "Scripts" &&
read_text(compiled_scripts / "Extensions/BuildOnly.hpp") ==
"#define BUILD_ONLY 1\n",
"Native C++ build receives the immutable source snapshot");
const auto repeated = builds.wait(builds.start_build());
check(repeated.state == "succeeded" &&
repeated.result.at("generation") == first.result.at("generation") &&
repeated.result.at("schema_cache_hit") == true &&
repeated.result.at("generation_reused") == true &&
read_text(config.project_root / "schema-export-count.txt") == "1",
"Unchanged native build reuses a verified schema generation");
atomic_write(path_from_utf8(first.result.at("schema").get<std::string>()), "truncated");
first = builds.wait(builds.start_build());
check(first.state == "succeeded" && first.result.at("schema_cache_hit") == false &&
read_text(config.project_root / "schema-export-count.txt") == "2",
"Corrupt schema cannot be a cache hit");
atomic_write(path_from_utf8(first.result.at("directory").get<std::string>()) /
"shaders/vertexMain.spv",
"corrupt");
first = builds.wait(builds.start_build());
check(first.state == "succeeded" && first.result.at("schema_cache_hit") == false &&
read_text(config.project_root / "schema-export-count.txt") == "3",
"Corrupt shader cannot be a cache hit");
const auto directory = path_from_utf8(first.result.at("directory").get<std::string>()); const auto directory = path_from_utf8(first.result.at("directory").get<std::string>());
for (const auto* entry : {"gpuVertexMain", "gpuShadowMain", "gpuCullMain", for (const auto* entry : {"gpuVertexMain", "gpuShadowMain", "gpuCullMain",
"gpuHzbMain", "gpuPostCullMain"}) "gpuHzbMain", "gpuPostCullMain"})
@@ -222,34 +161,6 @@ int test_main(int argc, char** argv) {
const auto previous_player = sha256_file(player); const auto previous_player = sha256_file(player);
const auto previous_schema = read_text(schema); const auto previous_schema = read_text(schema);
const auto previous_manifest = read_text(directory / "manifest.json"); const auto previous_manifest = read_text(directory / "manifest.json");
atomic_write(config.project_root / "emit-clang-error-and-fail-build", "fixture\n");
const auto compile_failed = builds.wait(builds.start_build());
check(compile_failed.state == "failed" &&
read_text(last_build) == previous_pointer &&
compile_failed.json().at("diagnostics").size() == 1 &&
compile_failed.json().at("diagnostics")[0].at("file") ==
"Scripts/Gameplay.cpp" &&
compile_failed.log.find("fixture compile failure") != std::string::npos,
"Failed compiler output retains raw log and structured source location");
fs::remove(config.project_root / "emit-clang-error-and-fail-build");
atomic_write(config.project_root / "fail-unparseable-build", "fixture\n");
const auto generic_failed = builds.wait(builds.start_build());
check(generic_failed.state == "failed" &&
generic_failed.json().at("diagnostics").size() == 1 &&
generic_failed.json().at("diagnostics")[0].at("severity") == "error" &&
!generic_failed.json().at("diagnostics")[0].contains("file") &&
read_text(last_build) == previous_pointer,
"Unparseable process failure has a non-navigable diagnostic");
fs::remove(config.project_root / "fail-unparseable-build");
atomic_write(config.project_root / "Scripts/Extensions/BuildOnly.hpp",
"#define BUILD_ONLY 3\n");
atomic_write(config.project_root / "mutate-cpp-header-during-build", "fixture\n");
const auto raced_header = builds.wait(builds.start_build());
check(raced_header.state == "failed" && read_text(last_build) == previous_pointer,
"Header changed during schema export cannot publish a mixed build");
fs::remove(config.project_root / "mutate-cpp-header-during-build");
atomic_write(config.project_root / "Scripts/Extensions/BuildOnly.hpp",
"#define BUILD_ONLY 1\n");
check(!first.result.at("lua_enabled").get<bool>() && check(!first.result.at("lua_enabled").get<bool>() &&
!fs::exists(directory / "project.faset.json"), !fs::exists(directory / "project.faset.json"),
"C++-only build publishes no Lua sources or project manifest"); "C++-only build publishes no Lua sources or project manifest");
@@ -258,11 +169,6 @@ int test_main(int argc, char** argv) {
"C++-only build explicitly disables the Lua VM in CMake"); "C++-only build explicitly disables the Lua VM in CMake");
authoring.replace_external_schemas(read_json(schema)); authoring.replace_external_schemas(read_json(schema));
const auto previous_registry = authoring.schemas().manifest(); const auto previous_registry = authoring.schemas().manifest();
std::size_t published_generations{};
for (const auto& entry : fs::directory_iterator(directory.parent_path())) {
(void)entry;
++published_generations;
}
check(authoring.schemas().schema("game.mover").at("version") == 2, check(authoring.schemas().schema("game.mover").at("version") == 2,
"Matching custom v2 metadata reaches authoring"); "Matching custom v2 metadata reaches authoring");
migration_contracts(config.project_root / "migration-contracts", read_json(schema)); migration_contracts(config.project_root / "migration-contracts", read_json(schema));
@@ -346,11 +252,9 @@ int test_main(int argc, char** argv) {
std::size_t generations{}; std::size_t generations{};
for (const auto& entry : fs::directory_iterator(directory.parent_path())) { for (const auto& entry : fs::directory_iterator(directory.parent_path())) {
++generations; ++generations;
check(!entry.path().filename().string().starts_with(".staging-"), check(entry.path() == directory, "Invalid build leaves no staging or generation");
"Invalid build leaves no staging generation");
} }
check(generations == published_generations, check(generations == 1, "Only the validated build generation remains");
"Invalid build publishes no new generation");
bool rejected{}; bool rejected{};
try { try {
authoring.replace_external_schemas(invalid[index]); authoring.replace_external_schemas(invalid[index]);
@@ -401,8 +305,6 @@ int test_main(int argc, char** argv) {
"Later edits never mutate an already published Lua generation"); "Later edits never mutate an already published Lua generation");
const auto lua_pointer = read_text(last_build); const auto lua_pointer = read_text(last_build);
for (const auto* marker : {"mutate-lua-during-build", "mutate-lua-snapshot"}) { for (const auto* marker : {"mutate-lua-during-build", "mutate-lua-snapshot"}) {
atomic_write(config.project_root / "Scripts/main.lua",
std::string(lua_source) + "-- force a schema export\n");
atomic_write(config.project_root / marker, "fixture\n"); atomic_write(config.project_root / marker, "fixture\n");
const auto raced = builds.wait(builds.start_build()); const auto raced = builds.wait(builds.start_build());
check(raced.state == "failed" && read_text(last_build) == lua_pointer, check(raced.state == "failed" && read_text(last_build) == lua_pointer,
+1 -16
View File
@@ -8,9 +8,6 @@ using namespace faset;
int tool_main(int argc, char** argv) { int tool_main(int argc, char** argv) {
try { try {
if (argc >= 3 && std::string_view(argv[1]) == "--output") { if (argc >= 3 && std::string_view(argv[1]) == "--output") {
const auto count_file = fs::path("schema-export-count.txt");
const auto count = fs::exists(count_file) ? std::stoi(read_text(count_file)) : 0;
atomic_write(count_file, std::to_string(count + 1));
if (argc == 5 && std::string_view(argv[3]) == "--project") { if (argc == 5 && std::string_view(argv[3]) == "--project") {
const auto snapshot = path_from_utf8(argv[4]); const auto snapshot = path_from_utf8(argv[4]);
const auto project = read_json(snapshot / "project.faset.json"); const auto project = read_json(snapshot / "project.faset.json");
@@ -24,8 +21,6 @@ int tool_main(int argc, char** argv) {
if (fs::exists("mutate-lua-snapshot")) if (fs::exists("mutate-lua-snapshot"))
atomic_write(snapshot / "Scripts/main.lua", "-- corrupt snapshot\n"); atomic_write(snapshot / "Scripts/main.lua", "-- corrupt snapshot\n");
} }
if (fs::exists("mutate-cpp-header-during-build"))
atomic_write("Scripts/Extensions/BuildOnly.hpp", "#define BUILD_ONLY 2\n");
atomic_write_json(path_from_utf8(argv[2]), read_json("schema-fixture.json")); atomic_write_json(path_from_utf8(argv[2]), read_json("schema-fixture.json"));
return 0; return 0;
} }
@@ -39,18 +34,8 @@ int tool_main(int argc, char** argv) {
std::cout << "Native packaging fixture validated\n"; std::cout << "Native packaging fixture validated\n";
return 0; return 0;
} }
if (argc > 2 && std::string_view(argv[1]) == "--build") { if (argc > 2 && std::string_view(argv[1]) == "--build")
if (fs::exists("emit-clang-error-and-fail-build")) {
std::cerr << path_to_utf8(fs::current_path() / "Scripts/Gameplay.cpp")
<< ":7:3: error: fixture compile failure\n";
return 1;
}
if (fs::exists("fail-unparseable-build")) {
std::cerr << "Synthetic native build failed without a source location\n";
return 1;
}
return 0; return 0;
}
fs::path build; fs::path build;
for (int i = 1; i + 1 < argc; ++i) for (int i = 1; i + 1 < argc; ++i)
if (std::string_view(argv[i]) == "-B") if (std::string_view(argv[i]) == "-B")
-101
View File
@@ -1,12 +1,8 @@
#include "assets_image_fixtures.hpp" #include "assets_image_fixtures.hpp"
#include <algorithm>
#include <atomic>
#include <bit> #include <bit>
#include <chrono> #include <chrono>
#include <cstdlib> #include <cstdlib>
#include <functional>
#include <faset/assets/asset_pipeline.hpp> #include <faset/assets/asset_pipeline.hpp>
#include <faset/authoring/service.hpp>
#include <faset/core/hash.hpp> #include <faset/core/hash.hpp>
#include <faset/core/io.hpp> #include <faset/core/io.hpp>
#include <faset/core/process.hpp> #include <faset/core/process.hpp>
@@ -46,102 +42,6 @@ Json scene(int dimension) {
{"name", "Build test"}, {"dimension", dimension}, {"entities", Json::array()}, {"name", "Build test"}, {"dimension", dimension}, {"entities", Json::array()},
{"instances", Json::array()}}; {"instances", Json::array()}};
} }
void starter_contracts(const fs::path& root) {
for (const int dimension : {2, 3}) {
for (const char* language : {"cpp", "lua"}) {
const auto project = root / path_from_utf8(std::string("Starter café ") + language +
std::to_string(dimension));
editor::BuildConfig config;
config.project_root = project;
config.engine_root = path_from_utf8(FASET_ENGINE_SOURCE);
editor::BuildService service(config);
service.scaffold("Starter", dimension, language);
const auto manifest = read_json(project / "project.faset.json");
const auto start = read_json(project / "Scenes/main.scene.json");
require(manifest.at("dimension") == dimension &&
manifest.at("start_scene") == "Scenes/main.scene.json",
"Explicit starter has the requested scene type and startup path");
require(start.at("dimension") == dimension && !start.at("entities").empty(),
"Starter scene is valid and has visible contents");
auto builtins = start;
bool has_behavior = false;
for (auto& entity : builtins["entities"]) {
auto& components = entity["components"].get_ref<Json::array_t&>();
std::erase_if(components, [&](const Json& component) {
const auto custom =
!component.at("type").get<std::string>().starts_with("faset.");
has_behavior |= custom;
return custom;
});
}
require(has_behavior, "Starter scene binds its gameplay behavior");
authoring::validate_scene(builtins, authoring::builtin_schemas());
if (std::string_view(language) == "lua") {
require(!fs::exists(project / "Scripts/Gameplay.cpp") &&
!fs::exists(project / "Scripts/Gameplay.hpp"),
"Lua starter has no C++ gameplay stub");
require(manifest.at("scripting").at("lua").at("scripts") ==
Json::array({"Scripts/main.lua"}) &&
scripting::loadLuaProject(project).enabled() &&
fs::is_regular_file(project / ".luarc.json") &&
fs::is_regular_file(project / "Scripts/.luarc.json") &&
fs::is_regular_file(project / ".faset/lua/faset.lua"),
"Lua starter declares an actual runnable module");
} else {
require(fs::is_regular_file(project / "Scripts/Gameplay.cpp") &&
fs::is_regular_file(project / "Scripts/Gameplay.hpp") &&
!manifest.contains("scripting"),
"C++ starter contains its compiled gameplay module");
}
const auto marker = read_text(project / "Scenes/main.scene.json");
bool rejected = false;
try {
service.scaffold("Again", dimension, language);
} catch (const std::exception&) {
rejected = true;
}
require(rejected && read_text(project / "Scenes/main.scene.json") == marker,
"Explicit creation rejects existing project without overwriting it");
}
}
editor::BuildConfig legacy;
legacy.project_root = root / "legacy";
legacy.engine_root = path_from_utf8(FASET_ENGINE_SOURCE);
editor::BuildService(legacy).scaffold("Legacy", 3);
require(!fs::exists(legacy.project_root / "Scenes/main.scene.json"),
"Two-argument scaffold retains its prior no-scene behavior");
const auto concurrent_root = root / "concurrent";
editor::BuildConfig concurrent;
concurrent.project_root = concurrent_root;
concurrent.engine_root = path_from_utf8(FASET_ENGINE_SOURCE);
editor::BuildService first(concurrent), second(concurrent);
std::atomic<int> ready = 0;
std::atomic<int> successes = 0;
auto attempt = [&](editor::BuildService& service, const char* name) {
++ready;
while (ready.load() < 2)
std::this_thread::yield();
try {
service.scaffold(name, 2, "lua");
++successes;
} catch (const std::exception&) {
}
};
std::thread a(attempt, std::ref(first), "First");
std::thread b(attempt, std::ref(second), "Second");
a.join();
b.join();
require(successes == 1, "Two concurrent creators publish exactly one starter");
const auto winner = read_json(concurrent_root / "project.faset.json");
require(winner.at("name") == "First" || winner.at("name") == "Second",
"Published manifest belongs to the successful creator");
require(fs::is_regular_file(concurrent_root / "Scripts/main.lua") &&
fs::is_regular_file(concurrent_root / "Scenes/main.scene.json"),
"The winning starter publishes all required files");
const auto ignore = read_text(concurrent_root / ".gitignore");
require(ignore.find("!.faset/lua/faset.lua") != std::string::npos,
"LuaLS declarations are commit-friendly in generated projects");
}
void lua_project_contracts(const fs::path& root) { void lua_project_contracts(const fs::path& root) {
fs::create_directories(root); fs::create_directories(root);
require(!scripting::loadLuaProject(root).enabled(), "No manifest means no Lua dependency"); require(!scripting::loadLuaProject(root).enabled(), "No manifest means no Lua dependency");
@@ -479,7 +379,6 @@ int test_main(int argc, char** argv) {
try { try {
fs::create_directories(temporary); fs::create_directories(temporary);
lua_project_contracts(temporary / "lua-project"); lua_project_contracts(temporary / "lua-project");
starter_contracts(temporary / "starters");
const auto original_executable = fs::absolute(path_from_utf8(argv[0])); const auto original_executable = fs::absolute(path_from_utf8(argv[0]));
const auto executable = temporary / original_executable.filename(); const auto executable = temporary / original_executable.filename();
fs::copy_file(original_executable, executable); fs::copy_file(original_executable, executable);
+1 -36
View File
@@ -1,18 +1,9 @@
#include <faset/core/io.hpp> #include <faset/core/io.hpp>
#include <faset/editor/session.hpp> #include <faset/editor/session.hpp>
#include <chrono>
#include <iostream> #include <iostream>
#include <thread>
int main(int argc, char** argv) { int main() {
using namespace faset; using namespace faset;
if (argc >= 3 && std::string_view(argv[1]) == "--editor-probe") {
Json arguments = Json::array();
for (int index = 3; index < argc; ++index)
arguments.push_back(argv[index]);
atomic_write_json(path_from_utf8(argv[2]), arguments);
return 0;
}
const auto root = std::filesystem::temp_directory_path() / const auto root = std::filesystem::temp_directory_path() /
path_from_utf8("Faset Café 世界 session " + new_id()); path_from_utf8("Faset Café 世界 session " + new_id());
try { try {
@@ -124,32 +115,6 @@ int main(int argc, char** argv) {
{{"path", "Scripts/behavior.lua"}, {"editor", {"{file}"}}}, "lua.editor"); {{"path", "Scripts/behavior.lua"}, {"editor", {"{file}"}}}, "lua.editor");
reject_command("faset_script_open", {{"path", "Scripts/behavior.lua"}, {"editor", {""}}}, reject_command("faset_script_open", {{"path", "Scripts/behavior.lua"}, {"editor", {""}}},
"lua.editor"); "lua.editor");
const auto probe = root / "editor-argv.json";
const auto opened = commands.call(
"faset_source_open",
{{"path", "Scripts/Gameplay.cpp"}, {"line", 17}, {"column", 4},
{"editor", Json::array({path_to_utf8(std::filesystem::absolute(path_from_utf8(argv[0]))),
"--editor-probe", path_to_utf8(probe),
"{file}:{line}:{column}", "{project}"})}});
for (int attempt = 0; attempt < 100 && !std::filesystem::exists(probe); ++attempt)
std::this_thread::sleep_for(std::chrono::milliseconds(10));
require(opened.at("path") == "Scripts/Gameplay.cpp" && opened.at("line") == 17 &&
std::filesystem::is_regular_file(probe) &&
read_json(probe) ==
Json::array({path_to_utf8(root / "Scripts/Gameplay.cpp") + ":17:4",
path_to_utf8(root)}),
"test", "Source navigation passes a Unicode path and location as literal argv");
reject_command("faset_source_open", {{"path", "../outside.cpp"}}, "source.path");
reject_command("faset_source_open", {{"path", "Scripts/missing.cpp"}}, "source.path");
reject_command("faset_source_open", {{"path", "Scripts"}}, "source.path");
atomic_write(root / "Scripts/tool.exe", "not a source\n");
reject_command("faset_source_open", {{"path", "Scripts/tool.exe"}}, "source.path");
std::filesystem::create_directories(root / "Outside");
std::error_code link_error;
std::filesystem::create_symlink(root / "Outside/secret.cpp",
root / "Scripts/escape.cpp", link_error);
if (!link_error)
reject_command("faset_source_open", {{"path", "Scripts/escape.cpp"}}, "source.path");
auto bad_lua_project = session.project(); auto bad_lua_project = session.project();
bad_lua_project["scripting"] = {{"lua", {{"scripts", {"Scripts/missing.lua"}}}}}; bad_lua_project["scripting"] = {{"lua", {{"scripts", {"Scripts/missing.lua"}}}}};
atomic_write_json(root / "project.faset.json", bad_lua_project); atomic_write_json(root / "project.faset.json", bad_lua_project);
-17
View File
@@ -111,9 +111,6 @@ int main() {
text(launcher, "launcher-path", path_to_utf8(root / "." / new_project.filename())); text(launcher, "launcher-path", path_to_utf8(root / "." / new_project.filename()));
click(launcher, "launcher-2d"); click(launcher, "launcher-2d");
check(launcher.widgets().find("launcher-2d")->selected, "2D project selection"); check(launcher.widgets().find("launcher-2d")->selected, "2D project selection");
click(launcher, "launcher-lua");
check(launcher.widgets().find("launcher-lua")->selected,
"Lua project language selection");
renderer.render(launcher.snapshot()); renderer.render(launcher.snapshot());
renderer.capture(root / "launcher-create.ppm"); renderer.capture(root / "launcher-create.ppm");
launcher.frame({key("Return", true)}); launcher.frame({key("Return", true)});
@@ -121,7 +118,6 @@ int main() {
"Create through keyboard rejected: " + "Create through keyboard rejected: " +
launcher.widgets().find("launcher-error")->text); launcher.widgets().find("launcher-error")->text);
check(launcher.selection()->create && launcher.selection()->dimension == 2 && check(launcher.selection()->create && launcher.selection()->dimension == 2 &&
launcher.selection()->language == "lua" &&
launcher.selection()->name == "Тестовый проект", launcher.selection()->name == "Тестовый проект",
"Create through keyboard preserves typed project metadata"); "Create through keyboard preserves typed project metadata");
check(launcher.selection()->path == new_project, check(launcher.selection()->path == new_project,
@@ -131,19 +127,6 @@ int main() {
check(!std::filesystem::exists(new_project), check(!std::filesystem::exists(new_project),
"Launcher does not create a partial project before Session scaffold"); "Launcher does not create a partial project before Session scaffold");
} }
{
editor::ProjectSelection retry{new_project, "Retry starter", 3, true, "lua"};
editor::ProjectLauncher launcher(renderer, path_from_utf8(FASET_TEST_ENGINE), {},
recents, retry, "Template source is unavailable");
launcher.frame({});
check(launcher.widgets().find("launcher-create")->selected &&
launcher.widgets().find("launcher-lua")->selected &&
launcher.widgets().find("launcher-path")->text == path_to_utf8(new_project) &&
launcher.widgets().find("launcher-name")->text == "Retry starter" &&
launcher.widgets().find("launcher-error")->text ==
"Template source is unavailable",
"Failed starter returns to Create with the chosen options and error");
}
{ {
editor::ProjectLauncher launcher(renderer, path_from_utf8(FASET_TEST_ENGINE), editor::ProjectLauncher launcher(renderer, path_from_utf8(FASET_TEST_ENGINE),
root / "Missing", recents); root / "Missing", recents);
@@ -59,6 +59,15 @@ int main() {
faset::atomic_write_json(reflection_file, metadata); faset::atomic_write_json(reflection_file, metadata);
must_reject([&] { (void)faset::render::detail::load_gpu_shader_bundle(temporary); }, must_reject([&] { (void)faset::render::detail::load_gpu_shader_bundle(temporary); },
"A consistently rehashed but incompatible GPU record stride must be rejected"); "A consistently rehashed but incompatible GPU record stride must be rejected");
faset::atomic_write_json(reflection_file,
faset::read_json(original / "gpuPostCullMain.reflection.json"));
reflection_file = temporary / "gpuVertexMain.reflection.json";
metadata = faset::read_json(original / "gpuVertexMain.reflection.json");
metadata["layout"]["descriptors"][0]["set"] = 1;
metadata["layout_fingerprint"] = faset::sha256(metadata["layout"].dump());
faset::atomic_write_json(reflection_file, metadata);
must_reject([&] { (void)faset::render::detail::load_gpu_shader_bundle(temporary); },
"GPU graphics scene buffers must stay in descriptor set two");
fs::remove(temporary / "gpuHzbMain.spv"); fs::remove(temporary / "gpuHzbMain.spv");
must_reject([&] { (void)faset::render::detail::load_gpu_shader_bundle(temporary); }, must_reject([&] { (void)faset::render::detail::load_gpu_shader_bundle(temporary); },
"Missing P2 entry must be rejected"); "Missing P2 entry must be rejected");
+191
View File
@@ -0,0 +1,191 @@
#include <faset/render/lighting.hpp>
#include <algorithm>
#include <array>
#include <cmath>
#include <iostream>
#include <stdexcept>
using namespace faset::render;
namespace {
void require(bool condition, const char* message) {
if (!condition)
throw std::runtime_error(message);
}
Snapshot fixture() {
Snapshot frame;
const Vec3 eye{0, 2, 8};
const auto view = look_at(eye, {0, 0, 0});
const auto projection = perspective(.9f, 16.f / 9.f, .1f, 120.f);
frame.eye = eye;
frame.view_projection = multiply(projection, view);
frame.projection = projection;
frame.camera_frustum = CameraFrustum{view, projection, .1f, 120.f, true};
frame.authored_lights_present = true;
frame.sun = SunLight{"sun", {-.7f, -.5f, -.3f}, {1, 1, 1, 1}, 1, true};
return frame;
}
LocalLight point_light(std::string id, int priority = 0) {
LocalLight light;
light.kind = LocalLight::Kind::Point;
light.stable_id = std::move(id);
light.position = {0, 2, 0};
light.range = 12;
light.shadow_priority = priority;
return light;
}
LocalLight spot_light(std::string id, int priority = 0) {
auto light = point_light(std::move(id), priority);
light.kind = LocalLight::Kind::Spot;
light.direction = {0, -1, 0};
return light;
}
bool contains_caster(const ShadowView& view, std::uint32_t draw_index) {
return std::find(view.caster_indices.begin(), view.caster_indices.end(), draw_index) !=
view.caster_indices.end();
}
void run() {
auto frame = fixture();
const auto plan = build_shadow_plan(frame, {}, {});
require(plan.sun_views.size() == 4 && plan.effective_sun_cascades == 4,
"Explicit camera receives four usable sun cascades");
require(plan.sun_views[0].split_near == .1f &&
std::abs(plan.sun_views.back().split_far - 80.f) < 1e-4f,
"Practical splits start at camera near and stop at shadow distance");
for (std::size_t i = 1; i < plan.sun_views.size(); ++i)
require(plan.sun_views[i].split_near == plan.sun_views[i - 1].split_far &&
plan.sun_views[i].split_far > plan.sun_views[i].split_near,
"Cascade split endpoints are strictly increasing and contiguous");
require(plan.sun_views[0].tile_index == 0 && plan.sun_views[3].tile_index == 3 &&
plan.sun_views[0].usable_size == 1020,
"Four guarded 1024-square tiles fit a 2048-square sun atlas");
auto shifted = frame;
shifted.eye[0] += .00001f;
const auto shifted_view = look_at(shifted.eye, {.00001f, 0, 0});
shifted.camera_frustum->view = shifted_view;
shifted.view_projection = multiply(shifted.projection, shifted_view);
const auto stable = build_shadow_plan(shifted, {}, {});
require(stable.sun_views[0].snapped_center_x == plan.sun_views[0].snapped_center_x &&
stable.sun_views[0].snapped_center_y == plan.sun_views[0].snapped_center_y,
"Subtexel camera translation retains the snapped sun projection origin");
const auto sun_direction = frame.sun->direction;
const auto inv_length = 1.f / std::hypot(sun_direction[0], sun_direction[1], sun_direction[2]);
const Vec3 upstream{-sun_direction[0] * inv_length * 18,
-sun_direction[1] * inv_length * 18,
-sun_direction[2] * inv_length * 18};
const ShadowCasterBounds offscreen{{{upstream[0] - .5f, upstream[1] - .5f,
upstream[2] - .5f},
{upstream[0] + .5f, upstream[1] + .5f,
upstream[2] + .5f}}, 7};
const ShadowCasterBounds outside{{{999, 0, 0}, {1001, 2, 2}}, 8};
const std::array casters{offscreen, outside};
const auto with_casters = build_shadow_plan(frame, casters, {});
require(std::any_of(with_casters.sun_views.begin(), with_casters.sun_views.end(),
[](const auto& view) { return contains_caster(view, 7); }),
"Offscreen upstream caster remains in a receiver's sun shadow view");
require(std::none_of(with_casters.sun_views.begin(), with_casters.sun_views.end(),
[](const auto& view) { return contains_caster(view, 8); }),
"Caster outside every sun XY footprint is excluded");
std::vector<ShadowCasterBounds> many(4097, {{{-.1f, -.1f, -.1f}, {.1f, .1f, .1f}}, 0});
for (std::uint32_t i = 0; i < many.size(); ++i)
many[i].draw_index = i;
const auto overdraw = build_shadow_plan(frame, many, {});
require(overdraw.caster_draws <= 4096 &&
std::any_of(overdraw.sun_views.begin(), overdraw.sun_views.end(),
[](const auto& view) {
return !view.valid && view.reason == ShadowDropReason::CasterBudget &&
view.caster_indices.empty();
}),
"A view with 4097 casters is skipped whole rather than partially rendered");
frame.sun.reset();
for (int i = 0; i < 15; ++i)
frame.local_lights.push_back(spot_light("spot-" + std::to_string(i), 10));
frame.local_lights.push_back(point_light("last-point"));
const auto capacity = build_shadow_plan(frame, {}, {});
require(capacity.local_faces_used == 15 && capacity.dropped_point_faces == 6 &&
capacity.local_faces_used <= 16 && capacity.caster_draws <= 4096,
"Insufficient room for six point faces drops the complete point shadow");
require(capacity.submitted_local_indices.size() == 16 &&
std::none_of(capacity.local_views.begin(), capacity.local_views.end(),
[](const auto& view) { return view.light_id == "last-point"; }),
"Atlas overflow leaves the point light in the lighting list, unshadowed");
frame.local_lights = {point_light("omnidirectional")};
const ShadowCasterBounds positive_x{{{3, -.2f, -.2f}, {3.4f, .2f, .2f}}, 19};
const auto point_faces = build_shadow_plan(frame, std::array{positive_x}, {});
require(point_faces.local_views.size() == 6 &&
contains_caster(point_faces.local_views[0], 19) &&
!contains_caster(point_faces.local_views[1], 19),
"Point-light caster behind the opposite face is culled from that face");
frame.local_lights.clear();
for (int i = 0; i < 15; ++i)
frame.local_lights.push_back(spot_light("spot-" + std::to_string(i), 10));
frame.local_lights.push_back(point_light("last-point"));
auto reversed = frame;
std::reverse(reversed.local_lights.begin(), reversed.local_lights.end());
const auto reordered = build_shadow_plan(reversed, {}, {});
require(reordered.local_views.size() == capacity.local_views.size(),
"Reversing input lights preserves scheduled view count");
for (std::size_t i = 0; i < capacity.local_views.size(); ++i)
require(reordered.local_views[i].light_id == capacity.local_views[i].light_id &&
reordered.local_views[i].tile_index == capacity.local_views[i].tile_index,
"Stable IDs preserve atlas assignments across input reordering");
frame.local_lights.clear();
for (int i = 0; i < 128; ++i) {
auto light = spot_light("ordinary-" + std::to_string(i));
light.casts_shadow = false;
frame.local_lights.push_back(light);
}
auto important = spot_light("late-high-priority", 5);
important.casts_shadow = false;
frame.local_lights.push_back(important);
const auto ranked = build_shadow_plan(frame, {}, {});
require(ranked.submitted_local_indices.size() == 128 &&
ranked.omitted_local_lights == 1 &&
ranked.submitted_local_indices.front() == 128,
"Submission selects all 128 by priority and reports one omitted light");
frame.local_lights.back().range = -1;
bool invalid_overflow_rejected = false;
try {
(void)build_shadow_plan(frame, {}, {});
} catch (const std::invalid_argument&) {
invalid_overflow_rejected = true;
}
require(invalid_overflow_rejected,
"All authored lights are validated even when beyond the submission cap");
frame.local_lights.clear();
frame.sun.reset();
const auto no_sun = build_shadow_plan(frame, {}, {});
require(no_sun.requested_sun_cascades == 0 && no_sun.sun_views.empty(),
"Authored lights suppress legacy sun even when none is enabled");
for (int i = 0; i < 20; ++i)
frame.local_lights.push_back(spot_light("over-cap-" + std::to_string(i)));
ShadowBudget relaxed;
relaxed.max_local_faces = 64;
relaxed.max_local_lights = 256;
const auto clamped = build_shadow_plan(frame, {}, relaxed);
require(clamped.local_faces_used == 16 && clamped.dropped_local_faces == 4,
"Fixed 4x4 local atlas never allocates outside its sixteen tiles");
frame = fixture();
frame.camera_frustum.reset();
const auto legacy = build_shadow_plan(frame, {}, {});
require(legacy.sun_views.size() == 1 && legacy.effective_sun_cascades == 1,
"A low-level snapshot without camera frustum uses one reported sun view");
frame = fixture();
ShadowBudget unsupported;
unsupported.sun_atlas_available = false;
const auto no_atlas = build_shadow_plan(frame, {}, unsupported);
require(no_atlas.effective_sun_cascades == 0 &&
no_atlas.dropped_sun_views == 4 &&
no_atlas.sun_views[0].reason == ShadowDropReason::Unavailable,
"Unsupported depth atlas yields an explicit unshadowed sun fallback");
}
} // namespace
int main() {
try {
run();
std::cout << "Shadow planning, stable allocation, caster visibility, and budgets passed\n";
return 0;
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return 1;
}
}
+35 -2
View File
@@ -59,6 +59,27 @@ int main() {
const auto original_reflection = read_text(bundle / "fragmentMain.reflection.json"); const auto original_reflection = read_text(bundle / "fragmentMain.reflection.json");
const auto original_fingerprint = Json::parse(original_reflection).at("layout_fingerprint"); const auto original_fingerprint = Json::parse(original_reflection).at("layout_fingerprint");
render::validate_shader_bundle(bundle); render::validate_shader_bundle(bundle);
auto bad_lighting_stride = Json::parse(original_reflection);
auto& lighting_descriptors = bad_lighting_stride["layout"]["descriptors"];
bool found_local_buffer = false;
for (auto& descriptor : lighting_descriptors)
if (descriptor["set"] == 1 && descriptor["binding"] == 1) {
descriptor["element_stride"] = 96;
found_local_buffer = true;
}
require(found_local_buffer, "Lighting stride fixture exists");
bad_lighting_stride["layout_fingerprint"] =
sha256(bad_lighting_stride["layout"].dump());
atomic_write_json(bundle / "fragmentMain.reflection.json", bad_lighting_stride);
bool rejected_lighting_stride = false;
try {
render::validate_shader_bundle(bundle);
} catch (const std::exception&) {
rejected_lighting_stride = true;
}
require(rejected_lighting_stride,
"Rehashed incompatible local-light element stride must be rejected");
atomic_write(bundle / "fragmentMain.reflection.json", original_reflection);
render::RendererConfig configuration; render::RendererConfig configuration;
configuration.width = configuration.height = 64; configuration.width = configuration.height = 64;
configuration.headless = true; configuration.headless = true;
@@ -161,6 +182,18 @@ int main() {
atomic_write(bundle / "fragmentMain.spv", "damaged bytecode"); atomic_write(bundle / "fragmentMain.spv", "damaged bytecode");
retained(); retained();
restore(); restore();
auto incompatible = original_source;
auto at = incompatible.find(" float4 reserved;");
require(at != std::string::npos, "Local-light stride fixture exists");
incompatible.replace(at, std::string(" float4 reserved;").size(),
" float4 reserved;\n float4 incompatibleExtraLane;");
atomic_write(source, incompatible);
require(compile(source, bundle) == 0, "Compile incompatible light-buffer stride");
require(read_json(bundle / "fragmentMain.reflection.json").at("layout_fingerprint") !=
original_fingerprint,
"Lighting stride edit changes normalized layout fingerprint");
retained();
restore();
auto malformed = original_spirv; auto malformed = original_spirv;
for (int i = 0; i < 4; ++i) for (int i = 0; i < 4; ++i)
malformed[20 + i] = 0; // zero-word SPIR-V instruction malformed[20 + i] = 0; // zero-word SPIR-V instruction
@@ -170,8 +203,8 @@ int main() {
atomic_write_json(bundle / "fragmentMain.reflection.json", metadata); atomic_write_json(bundle / "fragmentMain.reflection.json", metadata);
retained(); retained();
restore(); restore();
auto incompatible = original_source; incompatible = original_source;
auto at = incompatible.find("[[vk::binding(2,0)]]"); at = incompatible.find("[[vk::binding(2,0)]]");
require(at != std::string::npos, "Shader descriptor fixture exists"); require(at != std::string::npos, "Shader descriptor fixture exists");
incompatible.replace(at, std::string("[[vk::binding(2,0)]]").size(), incompatible.replace(at, std::string("[[vk::binding(2,0)]]").size(),
"[[vk::binding(7,0)]]"); "[[vk::binding(7,0)]]");
+96
View File
@@ -134,6 +134,102 @@ int main(int argc, char** argv) {
renderer.render(scene); renderer.render(scene);
pixels = renderer.pixels(); pixels = renderer.pixels();
require(pixels[index + 2] > 220, "Texture revision upload"); require(pixels[index + 2] > 220, "Texture revision upload");
Snapshot two_lights;
two_lights.eye = {0, 0, 6};
two_lights.projection = perspective(.85f, 320.f / 240.f, .1f, 30.f);
two_lights.view_projection =
multiply(two_lights.projection, look_at(two_lights.eye, {0, 0, 0}));
two_lights.authored_lights_present = true;
two_lights.draws.push_back(
{cube_mesh(), transform({-1.4f, 0, 0}), {.5f, .5f, .5f, 1}, .6f, 0, false});
two_lights.draws.back().instance_key = "left-light-receiver";
two_lights.draws.push_back(
{cube_mesh(), transform({1.4f, 0, 0}), {.5f, .5f, .5f, 1}, .6f, 0, false});
two_lights.draws.back().instance_key = "right-light-receiver";
two_lights.ui_quads.push_back({8, 8, 40, 20, {.8f, .1f, .15f, 1}});
for (auto mode : {VisibilityMode::Direct, VisibilityMode::GpuFrustum}) {
renderer.set_visibility_mode(mode);
renderer.render(two_lights);
const auto dark = renderer.pixels();
require(renderer.stats().validation_errors == 0,
"Zero-local-light descriptors are initialized");
auto legacy_lights = two_lights;
legacy_lights.authored_lights_present = false;
renderer.render(legacy_lights);
const auto legacy = renderer.pixels();
const auto left = (120 * 320 + 99) * 4;
require(legacy[left] > dark[left] + 15,
"Authored-light presence suppresses the legacy sun even without a local light");
two_lights.local_lights = {
{LocalLight::Kind::Point, "red", {-1.4f, 0, 1.4f}, {0, 0, -1},
{1, 0, 0, 1}, 8, 2.2f, .35f, .7f, false, 0},
{LocalLight::Kind::Point, "blue", {1.4f, 0, 1.4f}, {0, 0, -1},
{0, 0, 1, 1}, 8, 2.5f, .35f, .7f, false, 0}};
renderer.render(two_lights);
const auto lit = renderer.pixels();
const auto right = (120 * 320 + 221) * 4;
const auto ui = (10 * 320 + 10) * 4;
require(lit[left] > dark[left] + 20 && lit[right + 2] > dark[right + 2] + 20,
"Separated red and blue point lights illuminate their receivers");
require(std::abs(int(lit[left + 2]) - int(dark[left + 2])) < 6 &&
std::abs(int(lit[right]) - int(dark[right])) < 6,
"Local light range keeps the opposite colored light off each receiver");
for (int channel = 0; channel < 4; ++channel)
require(lit[ui + channel] == dark[ui + channel],
"Lighting changes leave UI tint unchanged");
require(renderer.stats().validation_errors == 0,
"Direct and GPU local lighting report no Vulkan errors");
if (mode == VisibilityMode::GpuFrustum)
require(renderer.stats().effective_visibility_mode == VisibilityMode::GpuFrustum,
"Local light image test actually exercises the GPU visibility path");
two_lights.local_lights[1].kind = LocalLight::Kind::Spot;
renderer.render(two_lights);
const auto aimed = renderer.pixels();
two_lights.local_lights[1].direction = {1, 0, 0};
renderer.render(two_lights);
const auto turned = renderer.pixels();
require(aimed[right + 2] > turned[right + 2] + 20,
"Spotlight cone direction changes receiver illumination");
two_lights.local_lights.clear();
}
renderer.set_visibility_mode(VisibilityMode::Direct);
renderer.render(two_lights);
const auto unlit_overflow = renderer.pixels();
for (int i = 0; i < 128; ++i) {
LocalLight local;
local.stable_id = "low-priority-" + std::to_string(i);
local.position = {20, 20, 20};
local.range = 1;
local.intensity = 0;
local.casts_shadow = false;
two_lights.local_lights.push_back(local);
}
LocalLight high;
high.stable_id = "last-high-priority";
high.position = {-1.4f, 0, 1.4f};
high.color = {1, 0, 0, 1};
high.range = 2.2f;
high.intensity = 8;
high.shadow_priority = 10;
high.casts_shadow = false;
two_lights.local_lights.push_back(high);
two_lights.local_lights.back().range = -1;
bool overflow_validation_failed = false;
try {
renderer.render(two_lights);
} catch (const std::invalid_argument&) {
overflow_validation_failed = true;
}
require(overflow_validation_failed,
"Renderer validates light records beyond the 128-light cap");
two_lights.local_lights.back().range = 2.2f;
renderer.render(two_lights);
const auto ranked_pixels = renderer.pixels();
const auto ranked_left = (120 * 320 + 99) * 4;
require(renderer.stats().submitted_local_lights == 128 &&
renderer.stats().omitted_local_lights == 1 &&
ranked_pixels[ranked_left] > unlit_overflow[ranked_left] + 20,
"High-priority last light is submitted and omitted count is observable");
if (argc > 2) if (argc > 2)
renderer.capture(argv[2]); renderer.capture(argv[2]);
renderer.resize(400, 300); renderer.resize(400, 300);
+128
View File
@@ -1,10 +1,12 @@
#include <bit> #include <bit>
#include <algorithm>
#include <cmath> #include <cmath>
#include <faset/assets/asset_pipeline.hpp> #include <faset/assets/asset_pipeline.hpp>
#include <faset/core/io.hpp> #include <faset/core/io.hpp>
#include <faset/player/SceneView.hpp> #include <faset/player/SceneView.hpp>
#include <faset/runtime/Runtime.hpp> #include <faset/runtime/Runtime.hpp>
#include <iostream> #include <iostream>
#include <limits>
#include <numbers> #include <numbers>
#include <stdexcept> #include <stdexcept>
@@ -23,12 +25,137 @@ template <class F> void rejects(F&& function, const char* message) {
} }
check(caught, message); check(caught, message);
} }
template <class F>
void rejectsContaining(F&& function, std::string_view entityId, std::string_view field) {
try {
function();
} catch (const std::exception& error) {
const std::string_view what(error.what());
check(what.find(entityId) != std::string_view::npos &&
what.find(field) != std::string_view::npos,
"Invalid light reports entity and field");
return;
}
throw std::runtime_error("Invalid light was accepted");
}
Json component(std::string type, Json fields) { Json component(std::string type, Json fields) {
return {{"id", type}, {"type", type}, {"version", 1}, {"fields", fields}}; return {{"id", type}, {"type", type}, {"version", 1}, {"fields", fields}};
} }
Json entity(std::string id, Json parent, Json components) { Json entity(std::string id, Json parent, Json components) {
return {{"id", id}, {"name", id}, {"parent", parent}, {"components", components}}; return {{"id", id}, {"name", id}, {"parent", parent}, {"components", components}};
} }
void lightingExtraction(faset::player::SceneView& view) {
auto scene = Json{{"format", "faset.scene"},
{"version", 1},
{"id", "lighting"},
{"name", "Lighting"},
{"dimension", 3},
{"instances", Json::array()},
{"entities", Json::array()}};
const auto empty = view.build(scene, 16.f / 9.f);
check(!empty.authored_lights_present && !empty.sun && empty.local_lights.empty(),
"Scene with no lights leaves legacy sun fallback available");
check(empty.camera_frustum && empty.camera_frustum->perspective &&
empty.camera_frustum->near_plane > 0 &&
empty.camera_frustum->far_plane > empty.camera_frustum->near_plane &&
faset::render::multiply(empty.camera_frustum->projection,
empty.camera_frustum->view) == empty.view_projection,
"3D extraction retains an unjittered camera frustum");
scene["entities"] = Json::array({
entity("sun", nullptr,
Json::array({component("faset.transform", {{"rotation", {0, .4, 0}}}),
component("faset.light", {{"kind", "directional"},
{"color", {1, .8, .6, 1}},
{"intensity", 2.5},
{"casts_shadow", false}})})),
entity("point", nullptr,
Json::array({component("faset.transform", {{"position", {2, 3, 4}}}),
component("faset.light", {{"kind", "point"},
{"color", {1, 0, 0, 1}},
{"intensity", 4},
{"range", 6},
{"shadow_priority", 3}})})),
entity("spot", nullptr,
Json::array({component("faset.transform", {{"position", {-2, 1, 0}}}),
component("faset.light", {{"kind", "spot"},
{"color", {0, 0, 1, 1}},
{"intensity", 3},
{"range", 8},
{"inner_angle", .2},
{"outer_angle", .6}})}))});
const auto a = view.build(scene, 16.f / 9.f);
check(a.authored_lights_present && a.sun && a.local_lights.size() == 2,
"Directional, point, and spot lights survive extraction");
check(a.sun->color[1] == .8f && a.sun->intensity == 2.5f && !a.sun->casts_shadow,
"Authored sun properties survive extraction");
check(a.local_lights[0].kind == faset::render::LocalLight::Kind::Point &&
a.local_lights[0].position == faset::render::Vec3{2, 3, 4} &&
a.local_lights[0].range == 6 && a.local_lights[0].shadow_priority == 3,
"Point fields and transform survive extraction");
check(a.local_lights[1].kind == faset::render::LocalLight::Kind::Spot &&
a.local_lights[1].inner_angle == .2f && a.local_lights[1].outer_angle == .6f,
"Spot cone survives extraction");
std::reverse(scene["entities"].begin(), scene["entities"].end());
const auto b = view.build(scene, 16.f / 9.f);
check(a.sun->stable_id == b.sun->stable_id &&
a.local_lights[0].stable_id == b.local_lights[0].stable_id &&
a.local_lights[1].stable_id == b.local_lights[1].stable_id,
"Light identity and ordering ignore entity array order");
scene["entities"].erase(scene["entities"].begin() + 2);
const auto localOnly = view.build(scene, 1);
check(localOnly.authored_lights_present && !localOnly.sun &&
localOnly.local_lights.size() == 2,
"Local-only lighting does not synthesize a sun");
scene["entities"] = Json::array({entity(
"disabled-sun", nullptr,
Json::array({component("faset.light", {{"kind", "directional"}, {"enabled", false}})}))});
const auto disabled = view.build(scene, 1);
check(disabled.authored_lights_present && !disabled.sun && disabled.local_lights.empty(),
"Explicit disabled sun suppresses legacy fallback");
scene["entities"][0]["components"][0]["version"] = 2;
const auto future = view.build(scene, 1);
check(future.authored_lights_present && !future.sun,
"Opaque future-version light still suppresses legacy fallback");
scene["entities"] = Json::array({
entity("sun-z", nullptr, Json::array({component("faset.light", Json::object())})),
entity("sun-a", nullptr, Json::array({component("faset.light", Json::object())}))});
const auto twoSuns = view.build(scene, 1);
check(twoSuns.sun && twoSuns.sun->stable_id.find("sun-a") != std::string::npos,
"Multiple suns select lowest stable identity");
check(!view.diagnostics().empty() &&
view.diagnostics().front().find("directional") != std::string::npos,
"Additional directionals produce an actionable diagnostic");
scene["entities"] = Json::array({entity(
"bad-light", nullptr,
Json::array({component("faset.light", {{"kind", "point"}, {"range", 0}})}))});
rejectsContaining([&] { view.build(scene, 1); }, "bad-light", "range");
scene["entities"][0]["components"][0]["fields"] =
{{"kind", "spot"}, {"range", 10}, {"inner_angle", .8}, {"outer_angle", .2}};
rejectsContaining([&] { view.build(scene, 1); }, "bad-light", "inner_angle");
scene["entities"][0]["components"][0]["fields"] = {{"kind", "area"}};
rejectsContaining([&] { view.build(scene, 1); }, "bad-light", "kind");
scene["entities"][0]["components"][0]["fields"] =
{{"kind", "point"}, {"shadow_priority", std::int64_t{2147483648}}};
rejectsContaining([&] { view.build(scene, 1); }, "bad-light", "shadow_priority");
scene["entities"][0]["components"][0]["fields"] = {{"kind", "point"}};
scene["entities"][0]["components"].insert(
scene["entities"][0]["components"].begin(),
component("faset.transform", {{"scale", {1, 1, 0}}}));
const auto flatPoint = view.build(scene, 1);
check(flatPoint.local_lights.size() == 1 &&
flatPoint.local_lights[0].kind == faset::render::LocalLight::Kind::Point,
"Point light accepts a zero Z scale because it needs only a position");
scene["entities"][0]["components"].erase(scene["entities"][0]["components"].begin());
scene["entities"][0]["components"][0]["fields"] =
{{"kind", "point"},
{"color", Json::array({1, std::numeric_limits<double>::quiet_NaN(), 1, 1})}};
rejectsContaining([&] { view.build(scene, 1); }, "bad-light", "color");
scene["entities"][0]["components"].insert(
scene["entities"][0]["components"].begin(),
component("faset.transform", {{"position", {0, 0, 0}},
{"scale", Json::array({1, std::numeric_limits<double>::quiet_NaN(), 1})}}));
rejectsContaining([&] { view.build(scene, 1); }, "bad-light", "transform");
}
void physicsDebug(faset::player::SceneView& view) { void physicsDebug(faset::player::SceneView& view) {
for (int dimension : {2, 3}) { for (int dimension : {2, 3}) {
const std::string bodyName = dimension == 2 ? "rigid_body_2d" : "rigid_body_3d"; const std::string bodyName = dimension == 2 ? "rigid_body_2d" : "rigid_body_3d";
@@ -159,6 +286,7 @@ void run() {
faset::atomic_write(folder / "version.fscene", version); faset::atomic_write(folder / "version.fscene", version);
rejects([&] { faset::player::readScene(folder / "version.fscene"); }, "reject cooked version"); rejects([&] { faset::player::readScene(folder / "version.fscene"); }, "reject cooked version");
faset::player::SceneView view(folder); faset::player::SceneView view(folder);
lightingExtraction(view);
physicsDebug(view); physicsDebug(view);
auto snapshot = view.build(scene, 16.f / 9.f); auto snapshot = view.build(scene, 16.f / 9.f);
check(snapshot.draws.size() == 1, "SceneView builtin mesh"); check(snapshot.draws.size() == 1, "SceneView builtin mesh");
+229
View File
@@ -0,0 +1,229 @@
"""Contract and arithmetic tests for the offline P3 lighting sweep wrapper."""
import csv
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "tools/benchmark_p3_lighting.py"
sys.path.insert(0, str(ROOT / "tools"))
def sample(shadows, visibility, lights, raster, gpu, repeat=1, frame=0):
return {
"shadows": shadows, "visibility": visibility, "light_count": str(lights),
"run_index": str(repeat), "frame": str(frame),
"gpu_main_raster_ms": str(raster), "gpu_ms": str(gpu),
"gpu_shadow_ms": "0", "cpu_ms": "1", "readback_cpu_ms": ".5",
"validation_errors": "0", "device": "Fake GPU", "driver": "Fake Driver",
"commit": "abc123", "effective_visibility": visibility,
"lighting_path": "forward", "submitted_local_lights": str(lights),
"omitted_local_lights": "0", "shadow_tiles": "0", "draw_calls": "1",
"gpu_bytes": "4096", "validation_enabled": "0", "width": "1920", "height": "1080",
}
FAKE_BENCHMARK = r'''import argparse
import csv
import json
from pathlib import Path
p = argparse.ArgumentParser()
for flag in ("lights", "run-index", "width", "height", "warmup", "frames"):
p.add_argument("--" + flag, type=int, required=True)
for flag in ("shadows", "visibility", "csv", "commit", "validation"):
p.add_argument("--" + flag, required=True)
p.add_argument("--driver")
a = p.parse_args()
path = Path(a.csv)
path.with_suffix(".args.json").write_text(json.dumps(vars(a)), encoding="utf-8")
fieldnames = ["light_count", "shadows", "visibility", "frame", "device", "driver",
"commit", "gpu_main_raster_ms", "gpu_ms", "gpu_shadow_ms", "cpu_ms",
"readback_cpu_ms", "validation_errors", "run_index", "effective_visibility",
"lighting_path", "submitted_local_lights", "omitted_local_lights",
"shadow_tiles", "draw_calls", "gpu_bytes", "validation_enabled", "width", "height"]
with path.open("w", newline="", encoding="utf-8") as stream:
writer = csv.DictWriter(stream, fieldnames=fieldnames)
writer.writeheader()
for frame in range(a.frames):
writer.writerow(dict(light_count=a.lights, shadows=a.shadows,
visibility=a.visibility, frame=frame, device="Fake GPU",
driver="Fake Driver", commit=a.commit,
gpu_main_raster_ms=.4 + .02 * a.lights, gpu_ms=4 + .02 * a.lights,
gpu_shadow_ms=0, cpu_ms=1, readback_cpu_ms=.5,
validation_errors=0, run_index=a.run_index,
effective_visibility=a.visibility, lighting_path="forward",
submitted_local_lights=a.lights, omitted_local_lights=0,
shadow_tiles=0, draw_calls=1, gpu_bytes=4096,
validation_enabled=0, width=a.width, height=a.height))
'''
class LightingBenchmarkTests(unittest.TestCase):
def test_list_runs_has_three_independent_repeats_for_each_shadow_setting(self):
process = subprocess.run([sys.executable, SCRIPT, "--list-runs"],
text=True, capture_output=True, check=True)
runs = json.loads(process.stdout)["runs"]
self.assertEqual(len(runs), 108)
for shadows in ("off", "on"):
group = [run for run in runs if run["shadows"] == shadows]
self.assertEqual(len(group), 54)
self.assertEqual({(run["light_count"], run["visibility"])
for run in group},
{(light, mode) for light in (0, 4, 16, 32, 64, 128)
for mode in ("direct", "gpu-frustum", "gpu-occlusion")})
self.assertEqual({(run["light_count"], run["visibility"], run["repeat"])
for run in group},
{(light, mode, repeat)
for light in (0, 4, 16, 32, 64, 128)
for mode in ("direct", "gpu-frustum", "gpu-occlusion")
for repeat in (1, 2, 3)})
def test_gate_uses_per_run_medians_and_same_mode_shadow_baseline(self):
from benchmark_p3_lighting import summarize_rows
rows = []
for repeat in (1, 2, 3):
for frame in (0, 1, 2):
rows.append(sample("off", "direct", 0, .5, 10, repeat, frame))
rows.append(sample("off", "direct", 32,
100 if repeat == 3 else 1.5, 11, repeat, frame))
rows.append(sample("on", "gpu-frustum", 0, .5, 4, repeat, frame))
rows.append(sample("on", "gpu-frustum", 64, 1.1, 4.6, repeat, frame))
rows.append(sample("off", "gpu-occlusion", 0, .5, 4, repeat, frame))
rows.append(sample("off", "gpu-occlusion", 128, 1.09, 4.59, repeat, frame))
summary = summarize_rows(rows)
hits = {(item["shadows"], item["visibility"], item["light_count"]): item
for item in summary["forward_plus_gate"]["candidates"]}
self.assertEqual(set(hits), {("off", "direct", 32),
("on", "gpu-frustum", 64)})
self.assertAlmostEqual(hits[("off", "direct", 32)]["overhead_ms"], 1.0)
self.assertAlmostEqual(hits[("on", "gpu-frustum", 64)]["overhead_ms"], .6)
self.assertEqual(hits[("off", "direct", 32)]["zero_light_gpu_ms"], 10)
self.assertEqual(hits[("on", "gpu-frustum", 64)]["zero_light_gpu_ms"], 4)
def test_sweep_runs_fake_executable_and_preserves_all_raw_frames(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
fake = root / "fake_benchmark.py"
fake.write_text(FAKE_BENCHMARK, encoding="utf-8")
output = root / "café 世界"
process = subprocess.run(
[sys.executable, SCRIPT, "--sweep", "--executable", fake,
"--output", output, "--shadows", "off", "--commit", "abc123",
"--driver", "Fake Driver"],
text=True, capture_output=True)
self.assertEqual(process.returncode, 0, process.stderr)
raw = list((output / "raw").glob("*.csv"))
self.assertEqual(len(raw), 54)
with (output / "merged.csv").open(newline="", encoding="utf-8") as stream:
merged = list(csv.DictReader(stream))
self.assertEqual(len(merged), 54 * 30)
self.assertEqual({row["source_csv"] for row in merged},
{path.name for path in raw})
report = json.loads((output / "summary.json").read_text(encoding="utf-8"))
self.assertEqual(report["runs_completed"], 54)
self.assertEqual(report["rows"], 54 * 30)
self.assertTrue(report["forward_plus_gate"]["triggered"])
one = json.loads(next((output / "raw").glob("*.args.json")).read_text())
self.assertEqual((one["width"], one["height"], one["warmup"], one["frames"]),
(1920, 1080, 10, 30))
self.assertEqual(one["validation"], "off")
def test_sweep_rejects_missing_gpu_raster_column(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
fake = root / "bad_benchmark.py"
fake.write_text(FAKE_BENCHMARK.replace(
'"gpu_main_raster_ms", "gpu_ms"', '"gpu_ms"').replace(
'gpu_main_raster_ms=.4 + .02 * a.lights, ', ''), encoding="utf-8")
output = root / "invalid"
process = subprocess.run(
[sys.executable, SCRIPT, "--sweep", "--executable", fake,
"--output", output, "--shadows", "off", "--commit", "abc123",
"--driver", "Fake Driver"],
text=True, capture_output=True)
self.assertNotEqual(process.returncode, 0)
self.assertIn("gpu_main_raster_ms", process.stderr)
self.assertFalse((output / "summary.json").exists())
def test_sweep_rejects_visibility_fallback_as_a_mode_measurement(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
fake = root / "fallback.py"
fake.write_text(FAKE_BENCHMARK.replace(
'effective_visibility=a.visibility', 'effective_visibility="direct"'),
encoding="utf-8")
output = root / "fallback-output"
process = subprocess.run(
[sys.executable, SCRIPT, "--sweep", "--executable", fake,
"--output", output, "--shadows", "off", "--commit", "abc123",
"--driver", "Fake Driver"],
text=True, capture_output=True)
self.assertNotEqual(process.returncode, 0)
self.assertIn("effective_visibility", process.stderr)
self.assertFalse((output / "summary.json").exists())
def test_sweep_rejects_a_scene_that_does_not_submit_requested_lights(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
fake = root / "wrong-count.py"
fake.write_text(FAKE_BENCHMARK.replace(
'submitted_local_lights=a.lights', 'submitted_local_lights=0'),
encoding="utf-8")
output = root / "wrong-count-output"
process = subprocess.run(
[sys.executable, SCRIPT, "--sweep", "--executable", fake,
"--output", output, "--shadows", "off", "--commit", "abc123",
"--driver", "Fake Driver"],
text=True, capture_output=True)
self.assertNotEqual(process.returncode, 0)
self.assertIn("submitted_local_lights", process.stderr)
self.assertFalse((output / "summary.json").exists())
def test_sweep_requires_known_driver_identity(self):
from benchmark_p3_lighting import sweep
with tempfile.TemporaryDirectory() as temporary:
fake = Path(temporary) / "fake.py"
fake.write_text(FAKE_BENCHMARK, encoding="utf-8")
with self.assertRaisesRegex(ValueError, "--driver"):
sweep(fake, Path(temporary) / "out", "off", "abc123")
def real_executable_smoke(executable: Path) -> None:
from benchmark_p3_lighting import REQUIRED_COLUMNS
choices = subprocess.run([executable, "--list-runs"], capture_output=True,
text=True, check=True)
declared = json.loads(choices.stdout)
if declared["lights"] != [0, 4, 16, 32, 64, 128] or len(declared["visibility"]) != 3:
raise AssertionError("C++ executable and Python sweep matrix disagree")
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "café 世界" / "smoke.csv"
subprocess.run([executable, "--lights", "4", "--shadows", "off",
"--visibility", "direct", "--width", "64", "--height", "64",
"--warmup", "0", "--frames", "1", "--validation", "on",
"--commit", "smoke", "--driver", "smoke-driver",
"--csv", output], capture_output=True, text=True, check=True)
with output.open(newline="", encoding="utf-8") as stream:
reader = csv.DictReader(stream)
columns, rows = reader.fieldnames or [], list(reader)
if set(REQUIRED_COLUMNS) - set(columns) or len(rows) != 1:
raise AssertionError("Real benchmark CSV lacks a complete single-frame row")
row = rows[0]
if (row["effective_visibility"] != "direct" or row["lighting_path"] != "forward" or
row["submitted_local_lights"] != "4" or row["validation_errors"] != "0" or
float(row["gpu_main_raster_ms"]) <= 0):
raise AssertionError("Real benchmark did not report the measured lighting path")
if __name__ == "__main__":
if len(sys.argv) == 3 and sys.argv[1] == "--real-executable":
real_executable_smoke(Path(sys.argv[2]).resolve())
else:
unittest.main()
+29
View File
@@ -36,6 +36,35 @@ def parameter(name: str, index: int, shape: str, access: str, stride: int | None
class ReflectionTests(unittest.TestCase): class ReflectionTests(unittest.TestCase):
def test_graphics_lighting_abi(self):
compiler = os.environ["FASET_TEST_SLANGC"]
with tempfile.TemporaryDirectory(prefix="faset-lighting-abi-") as directory:
for source, entry, defines in (
("baseline.slang", "fragmentMain", []),
("gpu_scene.slang", "gpuVertexMain", ["--define", "FASET_GPU_GRAPHICS=1"]),
):
process = subprocess.run(
[sys.executable, str(SCRIPT), "--compiler", compiler, "--source",
str(SCRIPT.parents[1] / "shaders" / source), "--entry", entry,
*defines, "--output", directory],
capture_output=True, text=True,
)
self.assertEqual(process.returncode, 0, process.stderr)
fragment = json.loads((Path(directory) / "fragmentMain.reflection.json").read_text())
gpu_vertex = json.loads((Path(directory) / "gpuVertexMain.reflection.json").read_text())
lighting = {
(d["set"], d["binding"]): (d["type"], d.get("element_stride"))
for d in fragment["layout"]["descriptors"]
}
self.assertEqual([lighting[1, i] for i in range(4)],
[("storage_buffer", 80), ("storage_buffer", 80),
("storage_buffer", 112), ("sampled_image_2d", None)])
graphics = {
(d["set"], d["binding"]): d["element_stride"]
for d in gpu_vertex["layout"]["descriptors"]
}
self.assertEqual([graphics[2, i] for i in range(3)], [224, 4, 208])
def test_gpu_vertex_paths_do_not_require_shader_draw_parameters(self): def test_gpu_vertex_paths_do_not_require_shader_draw_parameters(self):
# SV_InstanceID makes Slang subtract BaseInstance and emit DrawParameters. # SV_InstanceID makes Slang subtract BaseInstance and emit DrawParameters.
# Our indirect commands always use firstInstance=0, so the Vulkan instance # Our indirect commands always use firstInstance=0, so the Vulkan instance
+262
View File
@@ -0,0 +1,262 @@
#!/usr/bin/env python3
"""Run the fixed P3 lighting sweep and retain raw per-frame GPU measurements.
The Forward+ threshold in the summary is a measurement result, not an automatic
renderer switch. Apply it to the Linux physical reference GPU; keep other devices
as separate functional/performance observations.
"""
from __future__ import annotations
import argparse
import csv
import json
import math
from pathlib import Path
import statistics
import subprocess
import sys
LIGHT_COUNTS = (0, 4, 16, 32, 64, 128)
VISIBILITY_MODES = ("direct", "gpu-frustum", "gpu-occlusion")
REPEATS = (1, 2, 3)
WARMUP_FRAMES = 10
MEASURED_FRAMES = 30
WIDTH, HEIGHT = 1920, 1080
REQUIRED_COLUMNS = (
"light_count", "shadows", "visibility", "frame", "device", "driver",
"commit", "gpu_main_raster_ms", "gpu_ms", "gpu_shadow_ms", "cpu_ms",
"readback_cpu_ms", "validation_errors", "run_index", "effective_visibility",
"lighting_path", "submitted_local_lights", "omitted_local_lights",
"shadow_tiles", "draw_calls", "gpu_bytes", "validation_enabled", "width", "height",
)
TIMING_COLUMNS = ("gpu_main_raster_ms", "gpu_ms", "gpu_shadow_ms", "cpu_ms",
"readback_cpu_ms")
def build_runs(shadows: str = "both") -> list[dict]:
if shadows not in ("off", "on", "both"):
raise ValueError(f"Unsupported shadow setting: {shadows}")
settings = ("off", "on") if shadows == "both" else (shadows,)
return [{"shadows": shadow, "visibility": mode, "light_count": lights,
"repeat": repeat}
for shadow in settings for mode in VISIBILITY_MODES
for lights in LIGHT_COUNTS for repeat in REPEATS]
def median(values: list[float]) -> float:
if not values:
raise ValueError("Cannot summarize empty measurements")
return float(statistics.median(values))
def p95(values: list[float]) -> float:
if not values:
raise ValueError("Cannot summarize empty measurements")
ordered = sorted(values)
return ordered[math.ceil(.95 * len(ordered)) - 1]
def _measurement(row: dict, name: str) -> float:
try:
value = float(row[name])
except (KeyError, TypeError, ValueError) as error:
raise ValueError(f"Invalid {name} in benchmark CSV") from error
if not math.isfinite(value) or value < 0:
raise ValueError(f"Invalid {name} in benchmark CSV: {value}")
return value
def summarize_rows(rows: list[dict]) -> dict:
"""Use the median of each independent run's median, then compare like baselines."""
grouped: dict[tuple[str, str, int], dict[int, list[dict]]] = {}
for row in rows:
try:
key = (row["shadows"], row["visibility"], int(row["light_count"]))
repeat = int(row["run_index"])
except (KeyError, TypeError, ValueError) as error:
raise ValueError("Benchmark row lacks shadow/mode/light/repeat identity") from error
if key[0] not in ("off", "on") or key[1] not in VISIBILITY_MODES or repeat < 1:
raise ValueError(f"Invalid benchmark configuration: {key}, repeat {repeat}")
for name in TIMING_COLUMNS:
_measurement(row, name)
grouped.setdefault(key, {}).setdefault(repeat, []).append(row)
configurations = []
lookup = {}
for (shadows, visibility, lights), repeats in sorted(grouped.items()):
run_summaries = []
for repeat, samples in sorted(repeats.items()):
run_summaries.append({
"run_index": repeat, "frames": len(samples),
"median_ms": {name: median([_measurement(row, name) for row in samples])
for name in TIMING_COLUMNS},
})
entry = {
"shadows": shadows, "visibility": visibility, "light_count": lights,
"runs": run_summaries,
"median_ms": {name: median([run["median_ms"][name] for run in run_summaries])
for name in TIMING_COLUMNS},
"p95_ms": {name: p95([_measurement(row, name) for samples in repeats.values()
for row in samples]) for name in TIMING_COLUMNS},
}
configurations.append(entry)
lookup[(shadows, visibility, lights)] = entry
candidates = []
evaluated = []
for entry in configurations:
if entry["light_count"] not in (32, 64, 128):
continue
baseline = lookup.get((entry["shadows"], entry["visibility"], 0))
if baseline is None:
raise ValueError("Forward+ gate requires a zero-light baseline for each mode/shadow setting")
zero_gpu = baseline["median_ms"]["gpu_ms"]
if zero_gpu <= 0:
raise ValueError("Forward+ gate requires positive zero-light GPU frame timing")
overhead = (entry["median_ms"]["gpu_main_raster_ms"] -
baseline["median_ms"]["gpu_main_raster_ms"])
result = {"shadows": entry["shadows"], "visibility": entry["visibility"],
"light_count": entry["light_count"], "overhead_ms": overhead,
"zero_light_gpu_ms": zero_gpu,
"overhead_percent_of_zero_gpu": 100 * overhead / zero_gpu,
"absolute_threshold_reached": overhead >= 1.0,
"relative_threshold_reached": overhead >= .15 * zero_gpu}
evaluated.append(result)
if result["absolute_threshold_reached"] or result["relative_threshold_reached"]:
candidates.append(result)
return {"configurations": configurations,
"forward_plus_gate": {"triggered": bool(candidates), "candidates": candidates,
"evaluated": evaluated,
"basis": "median of three independent run medians; same-mode/shadow zero-light GPU baseline"}}
def _read_run_csv(path: Path, run: dict, commit: str) -> tuple[list[str], list[dict]]:
with path.open(newline="", encoding="utf-8") as stream:
reader = csv.DictReader(stream)
columns = reader.fieldnames or []
missing = sorted(set(REQUIRED_COLUMNS) - set(columns))
if missing:
raise ValueError(f"{path}: missing CSV column(s): {', '.join(missing)}")
rows = list(reader)
if len(rows) != MEASURED_FRAMES:
raise ValueError(f"{path}: expected {MEASURED_FRAMES} measured frames, got {len(rows)}")
frames = set()
for row in rows:
expected = {"light_count": str(run["light_count"]), "shadows": run["shadows"],
"visibility": run["visibility"], "run_index": str(run["repeat"]),
"commit": commit, "width": str(WIDTH), "height": str(HEIGHT)}
for name, value in expected.items():
if row[name] != value:
raise ValueError(f"{path}: {name} mismatch: expected {value}, got {row[name]}")
if row["effective_visibility"] != run["visibility"]:
raise ValueError(f"{path}: effective_visibility fell back from {run['visibility']}")
if row["submitted_local_lights"] != str(run["light_count"]) or row["omitted_local_lights"] != "0":
raise ValueError(f"{path}: submitted_local_lights or omitted_local_lights disagrees with the workload")
try:
frame = int(row["frame"])
errors = int(row["validation_errors"])
except ValueError as error:
raise ValueError(f"{path}: invalid frame or validation error count") from error
if frame in frames or errors != 0:
raise ValueError(f"{path}: duplicate frame or Vulkan validation error")
frames.add(frame)
if not row["device"] or not row["driver"] or not row["lighting_path"]:
raise ValueError(f"{path}: device, driver and effective lighting path are required")
for name in TIMING_COLUMNS:
_measurement(row, name)
return columns, rows
def _git_revision() -> str:
root = Path(__file__).resolve().parents[1]
return subprocess.check_output(["git", "-C", str(root), "rev-parse", "HEAD"],
text=True).strip()
def sweep(executable: Path, output: Path, shadows: str, commit: str,
validation: str = "off", driver: str | None = None) -> dict:
if not executable.is_file():
raise ValueError(f"Benchmark executable does not exist: {executable}")
if driver is None or not driver.strip() or driver.strip().lower() == "unknown":
raise ValueError("A measured sweep requires an explicit --driver identity")
if output.exists() and any(output.iterdir()):
raise ValueError(f"Output directory must be new or empty: {output}")
raw = output / "raw"
raw.mkdir(parents=True)
all_rows = []
columns = None
runs = build_runs(shadows)
command_prefix = [sys.executable, str(executable)] if executable.suffix.lower() == ".py" else [str(executable)]
for run in runs:
filename = (f"shadows-{run['shadows']}_{run['visibility']}_"
f"lights-{run['light_count']:03d}_run-{run['repeat']}.csv")
target = raw / filename
command = command_prefix + [
"--lights", str(run["light_count"]), "--shadows", run["shadows"],
"--visibility", run["visibility"], "--csv", str(target),
"--run-index", str(run["repeat"]), "--commit", commit,
"--validation", validation, "--width", str(WIDTH), "--height", str(HEIGHT),
"--warmup", str(WARMUP_FRAMES), "--frames", str(MEASURED_FRAMES),
]
if driver is not None:
command += ["--driver", driver]
result = subprocess.run(command, capture_output=True, text=True, encoding="utf-8",
errors="replace", timeout=180)
if result.returncode != 0:
raise RuntimeError(f"Benchmark failed for {filename}: {result.stderr[-2000:]}")
run_columns, samples = _read_run_csv(target, run, commit)
if columns is None:
columns = run_columns
elif columns != run_columns:
raise ValueError(f"{target}: CSV schema differs from other runs")
all_rows.extend({**row, "source_csv": filename} for row in samples)
merged = output / "merged.csv"
with merged.open("w", newline="", encoding="utf-8") as stream:
writer = csv.DictWriter(stream, fieldnames=[*(columns or []), "source_csv"])
writer.writeheader()
writer.writerows(all_rows)
summary = {"format": "faset.p3-lighting-benchmark", "version": 1,
"commit": commit, "warmup_frames_per_run": WARMUP_FRAMES,
"measured_frames_per_run": MEASURED_FRAMES, "width": WIDTH, "height": HEIGHT,
"validation": validation, "driver": driver,
"runs_completed": len(runs), "rows": len(all_rows),
**summarize_rows(all_rows)}
(output / "summary.json").write_text(json.dumps(summary, indent=2) + "\n",
encoding="utf-8")
return summary
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
mode = parser.add_mutually_exclusive_group(required=True)
mode.add_argument("--list-runs", action="store_true", help="Print the deterministic sweep matrix as JSON")
mode.add_argument("--sweep", action="store_true", help="Run every configuration and retain raw CSV")
parser.add_argument("--shadows", choices=("off", "on", "both"), default="both")
parser.add_argument("--executable", type=Path, help="Built C++ benchmark executable")
parser.add_argument("--output", type=Path, help="New or empty evidence directory")
parser.add_argument("--commit", help="Source revision; defaults to this checkout's HEAD")
parser.add_argument("--driver", help="Required driver identity for a measured sweep")
parser.add_argument("--validation", choices=("on", "off"), default="off")
args = parser.parse_args()
if args.list_runs:
print(json.dumps({"format": "faset.p3-lighting-run-matrix", "version": 1,
"runs": build_runs(args.shadows)}, indent=2))
return 0
if args.executable is None or args.output is None:
parser.error("--sweep requires --executable and --output")
try:
summary = sweep(args.executable.resolve(), args.output.resolve(), args.shadows,
args.commit or _git_revision(), args.validation, args.driver)
except (OSError, ValueError, RuntimeError) as error:
print(f"P3 lighting benchmark failed: {error}", file=sys.stderr)
return 1
print(json.dumps({"summary": str(args.output.resolve() / "summary.json"),
"runs_completed": summary["runs_completed"],
"forward_plus_threshold_reached": summary["forward_plus_gate"]["triggered"]}))
return 0
if __name__ == "__main__":
raise SystemExit(main())
-22
View File
@@ -1,22 +0,0 @@
-- Faset starter gameplay. Edit this file and use Refresh Lua or development reload;
-- the native C++ engine does not need to recompile for a Lua-only edit.
local Player = faset.behavior {
id = "starter.player",
version = 1,
name = "Player",
fields = {
speed = { name = "Move speed", type = "number", default = 4, min = 0 },
jump_speed = { name = "Jump speed", type = "number", default = 5, min = 0 }
}
}
function Player:fixed_update(delta)
local velocity = self.entity:velocity()
velocity.x = faset.input().horizontal * self.fields.speed
if faset.input().jump_pressed and self.entity:is_grounded() then
velocity.y = self.fields.jump_speed
end
self.entity:set_velocity(velocity)
end
return Player