Checkpoint 1: implement native subsystems and begin the gameplay manual

This commit is contained in:
Emil
2026-09-18 03:01:30 +03:00
parent decf49084d
commit 903c97444b
73 changed files with 3932 additions and 6 deletions
+5
View File
@@ -0,0 +1,5 @@
* text=auto
# Preserve upstream notice text, including its original whitespace.
licenses/*.txt -whitespace
*.png binary
*.ttf binary
+51
View File
@@ -0,0 +1,51 @@
name: Native and manual checks
on:
push:
pull_request:
permissions:
contents: read
jobs:
native:
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, windows-2025]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: '3.12'
- name: Windows compiler environment
if: runner.os == 'Windows'
uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756
- name: Linux build tools
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y clang ninja-build
- name: Configure Linux
if: runner.os == 'Linux'
run: cmake --preset linux-debug -DFASET_BUILD_RENDERER=OFF -DFASET_BUILD_EDITOR=OFF
- name: Configure Windows
if: runner.os == 'Windows'
run: cmake --preset windows-debug -DFASET_BUILD_RENDERER=OFF -DFASET_BUILD_EDITOR=OFF
- name: Build Linux
if: runner.os == 'Linux'
run: cmake --build --preset linux-debug --parallel 2
- name: Build Windows
if: runner.os == 'Windows'
run: cmake --build --preset windows-debug --parallel 2
- name: Test Linux
if: runner.os == 'Linux'
run: ctest --preset linux-debug
- name: Test Windows
if: runner.os == 'Windows'
run: ctest --preset windows-debug
manual:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: '3.12'
- run: python -m pip install -r docs/requirements.txt
- run: python -m mkdocs build --strict
+66
View File
@@ -0,0 +1,66 @@
cmake_minimum_required(VERSION 3.25)
project(FasetEngine VERSION 0.1.0 LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
option(FASET_BUILD_RENDERER "Build the SDL3/Vulkan renderer and graphical applications" ON)
option(FASET_SANITIZERS "Enable address and undefined behavior sanitizers" OFF)
option(FASET_DEBUG_IMGUI "Build optional Dear ImGui diagnostics library" OFF)
option(FASET_BUILD_RUNTIME "Build ECS and physics runtime" ON)
option(FASET_BUILD_ASSETS "Build asset import tools" ON)
option(FASET_BUILD_AUTHORING "Build scene authoring and metadata" ON)
option(FASET_BUILD_EDITOR "Build retained UI and editor applications" ON)
include(CTest)
find_package(Threads REQUIRED)
if(FASET_SANITIZERS AND NOT MSVC)
add_compile_options(-fsanitize=address,undefined -fno-omit-frame-pointer)
add_link_options(-fsanitize=address,undefined)
endif()
if(MSVC)
add_compile_options(/utf-8 /W4 /permissive-)
else()
add_compile_options(-Wall -Wextra -Wpedantic)
endif()
include(cmake/Dependencies.cmake)
add_library(faset_core STATIC src/core/hash.cpp src/core/io.cpp)
target_include_directories(faset_core PUBLIC include)
target_link_libraries(faset_core PUBLIC nlohmann_json::nlohmann_json Threads::Threads)
target_compile_definitions(faset_core PUBLIC FASET_VERSION="${PROJECT_VERSION}")
# Modules are independent targets; Player never links authoring, editor or MCP.
foreach(module Authoring Runtime Assets)
if(module STREQUAL "Authoring" AND NOT FASET_BUILD_AUTHORING)
continue()
endif()
if(module STREQUAL "Runtime" AND NOT FASET_BUILD_RUNTIME)
continue()
endif()
if(module STREQUAL "Assets" AND NOT FASET_BUILD_ASSETS)
continue()
endif()
if(EXISTS "${PROJECT_SOURCE_DIR}/cmake/${module}.cmake")
include(cmake/${module}.cmake)
endif()
endforeach()
if(FASET_BUILD_RENDERER AND EXISTS "${PROJECT_SOURCE_DIR}/cmake/Renderer.cmake")
include(cmake/Renderer.cmake)
endif()
foreach(module UI Editor Applications)
if(NOT FASET_BUILD_EDITOR)
continue()
endif()
if(EXISTS "${PROJECT_SOURCE_DIR}/cmake/${module}.cmake")
include(cmake/${module}.cmake)
endif()
endforeach()
if(BUILD_TESTING)
add_executable(faset_core_tests tests/core_tests.cpp)
target_link_libraries(faset_core_tests PRIVATE faset_core)
add_test(NAME core COMMAND faset_core_tests)
endif()
+23
View File
@@ -0,0 +1,23 @@
{
"version": 6,
"cmakeMinimumRequired": {"major": 3, "minor": 25, "patch": 0},
"configurePresets": [
{"name": "linux-debug", "displayName": "Linux Clang Debug", "generator": "Ninja", "binaryDir": "${sourceDir}/build/linux-debug", "condition": {"type": "equals", "lhs": "${hostSystemName}", "rhs": "Linux"}, "cacheVariables": {"CMAKE_BUILD_TYPE": "Debug", "CMAKE_C_COMPILER": "clang", "CMAKE_CXX_COMPILER": "clang++", "BUILD_TESTING": "ON"}},
{"name": "linux-release", "inherits": "linux-debug", "binaryDir": "${sourceDir}/build/linux-release", "cacheVariables": {"CMAKE_BUILD_TYPE": "Release"}},
{"name": "linux-sanitize", "inherits": "linux-debug", "binaryDir": "${sourceDir}/build/linux-sanitize", "cacheVariables": {"FASET_SANITIZERS": "ON", "FASET_BUILD_RENDERER": "OFF"}},
{"name": "windows-debug", "displayName": "Windows clang-cl Debug (Developer shell)", "generator": "Ninja", "binaryDir": "${sourceDir}/build/windows-debug", "condition": {"type": "equals", "lhs": "${hostSystemName}", "rhs": "Windows"}, "cacheVariables": {"CMAKE_BUILD_TYPE": "Debug", "CMAKE_C_COMPILER": "clang-cl", "CMAKE_CXX_COMPILER": "clang-cl", "BUILD_TESTING": "ON"}},
{"name": "windows-release", "inherits": "windows-debug", "binaryDir": "${sourceDir}/build/windows-release", "cacheVariables": {"CMAKE_BUILD_TYPE": "Release"}}
],
"buildPresets": [
{"name": "linux-debug", "configurePreset": "linux-debug"},
{"name": "linux-release", "configurePreset": "linux-release"},
{"name": "linux-sanitize", "configurePreset": "linux-sanitize"},
{"name": "windows-debug", "configurePreset": "windows-debug"},
{"name": "windows-release", "configurePreset": "windows-release"}
],
"testPresets": [
{"name": "linux-debug", "configurePreset": "linux-debug", "output": {"outputOnFailure": true}},
{"name": "linux-sanitize", "configurePreset": "linux-sanitize", "output": {"outputOnFailure": true}},
{"name": "windows-debug", "configurePreset": "windows-debug", "output": {"outputOnFailure": true}}
]
}
+1 -1
View File
@@ -2,7 +2,7 @@
Версия 1.1 · 18 сентября 2026 года.
**Статус:** базовая архитектура принята; реализация движка и MVP ещё не началась. Выполнены исследование исходников, выбор архитектуры и создание карты документации. Все пункты реализации ниже открыты. Этот документ определяет порядок работ; контракты подсистем находятся в [ARCHITECTURE.md](docs/ARCHITECTURE.md).
**Статус:** идёт реализация MVP. Готовность этапов определяется всеми их критериями, включая проверку обеих ОС; отдельные работающие подсистемы ещё не закрывают этап целиком. Текущие результаты и ограничения записаны в [журнале реализации](docs/IMPLEMENTATION.md). Этот документ определяет порядок работ; контракты подсистем находятся в [ARCHITECTURE.md](docs/ARCHITECTURE.md).
## 1. Результат MVP
+2 -1
View File
@@ -2,10 +2,11 @@
Faset is an independent engine project for desktop **2D and 3D games on Linux and Windows**. Its priorities are a custom editor that is comfortable to use by hand and through MCP, integration with Blender, and a path toward advanced graphics.
**Current status: the core architecture is agreed; engine implementation has not started.** This repository contains the architecture decisions, development plan, source studies, and a working browser-based documentation map. A technology appearing in the plan does not mean it has been implemented or benchmarked.
**Current status: MVP implementation is in progress.** Native core, authoring, physics runtime, asset import, and renderer subsystems are being integrated and tested. The complete editor and game export workflow are not yet ready. See the [implementation checkpoints](docs/IMPLEMENTATION.md) for observed results; a technology appearing in the plan does not mean it is complete or benchmarked.
## Start here
- [User manual](docs/manual/index.md) — learn C++ gameplay and follow working examples; maintained alongside implementation.
- [Development plan](PLAN.md) — milestones through MVP, acceptance criteria, and development beyond MVP.
- [Architecture](docs/ARCHITECTURE.md) — accepted decisions and subsystem boundaries.
- [Documentation](docs/README.md) — navigation and maintenance rules.
+15
View File
@@ -0,0 +1,15 @@
add_library(faset_assets
${PROJECT_SOURCE_DIR}/src/assets/asset_pipeline.cpp
${PROJECT_SOURCE_DIR}/src/assets/cgltf.cpp)
target_compile_features(faset_assets PUBLIC cxx_std_20)
target_include_directories(faset_assets PUBLIC ${PROJECT_SOURCE_DIR}/include)
target_link_libraries(faset_assets PUBLIC faset_core nlohmann_json::nlohmann_json PRIVATE faset_cgltf)
if(BUILD_TESTING)
add_executable(faset_assets_tests ${PROJECT_SOURCE_DIR}/tests/assets_pipeline.cpp)
target_link_libraries(faset_assets_tests PRIVATE faset_assets)
add_test(NAME assets_pipeline COMMAND faset_assets_tests)
find_package(Python3 COMPONENTS Interpreter QUIET)
if(Python3_Interpreter_FOUND)
add_test(NAME assets_blender_bundle COMMAND ${Python3_EXECUTABLE} ${PROJECT_SOURCE_DIR}/tests/assets_bundle_test.py)
endif()
endif()
+8
View File
@@ -0,0 +1,8 @@
add_library(faset_authoring STATIC src/authoring/schema.cpp src/authoring/service.cpp src/authoring/templates.cpp)
target_include_directories(faset_authoring PUBLIC "${PROJECT_SOURCE_DIR}/include")
target_link_libraries(faset_authoring PUBLIC faset_core)
if(BUILD_TESTING)
add_executable(faset_authoring_tests tests/authoring_tests.cpp)
target_link_libraries(faset_authoring_tests PRIVATE faset_authoring)
add_test(NAME authoring COMMAND faset_authoring_tests)
endif()
+62
View File
@@ -0,0 +1,62 @@
include(FetchContent)
file(READ "${PROJECT_SOURCE_DIR}/dependencies.lock.json" FASET_DEPENDENCY_LOCK)
function(faset_dependency name)
string(JSON url GET "${FASET_DEPENDENCY_LOCK}" dependencies ${name} url)
string(JSON hash GET "${FASET_DEPENDENCY_LOCK}" dependencies ${name} sha256)
string(JSON commit GET "${FASET_DEPENDENCY_LOCK}" dependencies ${name} commit)
set(archive "${PROJECT_SOURCE_DIR}/.cache/downloads/${name}-${commit}.tar.gz")
if(EXISTS "${archive}")
set(url "${archive}")
endif()
FetchContent_Declare(${name} URL "${url}" URL_HASH "SHA256=${hash}" DOWNLOAD_EXTRACT_TIMESTAMP TRUE)
FetchContent_MakeAvailable(${name})
FetchContent_GetProperties(${name} SOURCE_DIR source)
set(FASET_${name}_SOURCE_DIR "${source}" PARENT_SCOPE)
endfunction()
set(JSON_BuildTests OFF CACHE BOOL "" FORCE)
set(JSON_Install OFF CACHE BOOL "" FORCE)
set(ENTT_BUILD_TESTING OFF CACHE BOOL "" FORCE)
set(BOX2D_SAMPLES OFF CACHE BOOL "" FORCE)
set(BOX2D_UNIT_TESTS OFF CACHE BOOL "" FORCE)
set(BOX2D_BENCHMARKS OFF CACHE BOOL "" FORCE)
set(BOX2D_AVX2 OFF CACHE BOOL "" FORCE)
set(BOX3D_SAMPLES OFF CACHE BOOL "" FORCE)
set(BOX3D_UNIT_TESTS OFF CACHE BOOL "" FORCE)
set(BOX3D_BENCHMARKS OFF CACHE BOOL "" FORCE)
set(BOX3D_BUILD_SHADERS OFF CACHE BOOL "" FORCE)
faset_dependency(json)
faset_dependency(entt)
faset_dependency(box2d)
faset_dependency(box3d)
faset_dependency(cgltf)
add_library(faset_cgltf INTERFACE)
target_include_directories(faset_cgltf INTERFACE "${FASET_cgltf_SOURCE_DIR}")
faset_dependency(stb)
add_library(faset_stb INTERFACE)
target_include_directories(faset_stb INTERFACE "${FASET_stb_SOURCE_DIR}")
# Keep the CRT compatible across Faset and physics libraries, including editor plugins.
if(MSVC)
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
set_property(TARGET box2d box3d PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
endif()
if(FASET_BUILD_RENDERER)
set(SDL_SHARED OFF CACHE BOOL "" FORCE)
set(SDL_STATIC ON CACHE BOOL "" FORCE)
set(SDL_TEST_LIBRARY OFF CACHE BOOL "" FORCE)
set(SDL_TESTS OFF CACHE BOOL "" FORCE)
set(SDL_EXAMPLES OFF CACHE BOOL "" FORCE)
set(SDL_INSTALL OFF CACHE BOOL "" FORCE)
faset_dependency(sdl3)
find_package(Vulkan 1.3 REQUIRED)
endif()
if(FASET_DEBUG_IMGUI)
faset_dependency(imgui)
add_library(faset_imgui STATIC
"${FASET_imgui_SOURCE_DIR}/imgui.cpp" "${FASET_imgui_SOURCE_DIR}/imgui_draw.cpp"
"${FASET_imgui_SOURCE_DIR}/imgui_tables.cpp" "${FASET_imgui_SOURCE_DIR}/imgui_widgets.cpp")
target_include_directories(faset_imgui PUBLIC "${FASET_imgui_SOURCE_DIR}")
endif()
+39
View File
@@ -0,0 +1,39 @@
find_package(Vulkan 1.3 REQUIRED)
find_package(Python3 COMPONENTS Interpreter REQUIRED)
find_program(SLANGC_EXECUTABLE NAMES slangc HINTS "${PROJECT_SOURCE_DIR}/.cache/slang/bin" "$ENV{VULKAN_SDK}/bin")
if(NOT SLANGC_EXECUTABLE)
message(FATAL_ERROR "Slang compiler missing. Run: python tools/fetch_slang.py, or set SLANGC_EXECUTABLE.")
endif()
set(FASET_SHADER_DIRECTORY "${CMAKE_BINARY_DIR}/shaders")
file(MAKE_DIRECTORY "${FASET_SHADER_DIRECTORY}")
set(FASET_SHADER_OUTPUTS)
foreach(FASET_ENTRY vertexMain fragmentMain shadowMain)
set(FASET_SHADER_OUTPUT "${FASET_SHADER_DIRECTORY}/${FASET_ENTRY}.spv")
add_custom_command(OUTPUT "${FASET_SHADER_OUTPUT}"
COMMAND "${SLANGC_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/shaders/baseline.slang"
-entry "${FASET_ENTRY}" -target spirv -profile spirv_1_6 -matrix-layout-column-major
-o "${FASET_SHADER_OUTPUT}" -reflection-json "${FASET_SHADER_DIRECTORY}/${FASET_ENTRY}.reflection.json"
BYPRODUCTS "${FASET_SHADER_DIRECTORY}/${FASET_ENTRY}.reflection.json"
DEPENDS "${PROJECT_SOURCE_DIR}/shaders/baseline.slang" VERBATIM)
list(APPEND FASET_SHADER_OUTPUTS "${FASET_SHADER_OUTPUT}")
endforeach()
add_custom_command(OUTPUT "${FASET_SHADER_DIRECTORY}/compatibility.spv"
COMMAND "${SLANGC_EXECUTABLE}" "${PROJECT_SOURCE_DIR}/shaders/compatibility.hlsl"
-entry compatibilityMain -stage compute -target spirv -profile spirv_1_6
-o "${FASET_SHADER_DIRECTORY}/compatibility.spv"
DEPENDS "${PROJECT_SOURCE_DIR}/shaders/compatibility.hlsl" VERBATIM)
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")
target_include_directories(faset_render PUBLIC "${PROJECT_SOURCE_DIR}/include")
target_compile_features(faset_render PUBLIC cxx_std_20)
target_link_libraries(faset_render PRIVATE Vulkan::Vulkan SDL3::SDL3)
target_compile_definitions(faset_render PRIVATE FASET_SHADER_DIRECTORY="${FASET_SHADER_DIRECTORY}")
add_dependencies(faset_render faset_shaders)
if(BUILD_TESTING)
add_executable(faset_render_tests "${PROJECT_SOURCE_DIR}/tests/render_tests.cpp")
target_link_libraries(faset_render_tests PRIVATE faset_render)
add_test(NAME render_graph COMMAND faset_render_tests --unit)
add_test(NAME render_offscreen COMMAND faset_render_tests --gpu "${CMAKE_BINARY_DIR}/render-test.ppm")
set_tests_properties(render_offscreen PROPERTIES LABELS "gpu")
endif()
install(FILES ${FASET_SHADER_OUTPUTS} DESTINATION shaders)
+18
View File
@@ -0,0 +1,18 @@
add_library(faset_runtime STATIC
${CMAKE_CURRENT_LIST_DIR}/../src/runtime/Runtime.cpp
${CMAKE_CURRENT_LIST_DIR}/../src/runtime/Physics.cpp)
add_library(Faset::Runtime ALIAS faset_runtime)
target_compile_features(faset_runtime PUBLIC cxx_std_20)
target_include_directories(faset_runtime PUBLIC ${CMAKE_CURRENT_LIST_DIR}/../include)
target_link_libraries(faset_runtime PUBLIC nlohmann_json::nlohmann_json PRIVATE EnTT::EnTT box2d box3d)
add_library(faset_gameplay STATIC ${CMAKE_CURRENT_LIST_DIR}/../examples/gameplay/Gameplay.cpp)
add_library(Faset::Gameplay ALIAS faset_gameplay)
target_include_directories(faset_gameplay PUBLIC ${CMAKE_CURRENT_LIST_DIR}/../examples/gameplay)
target_link_libraries(faset_gameplay PUBLIC faset_runtime)
if(BUILD_TESTING)
add_executable(faset_runtime_tests ${CMAKE_CURRENT_LIST_DIR}/../tests/runtime_tests.cpp)
target_link_libraries(faset_runtime_tests PRIVATE faset_runtime faset_gameplay)
add_test(NAME runtime_contracts COMMAND faset_runtime_tests)
endif()
+69
View File
@@ -0,0 +1,69 @@
{
"format": 1,
"dependencies": {
"sdl3": {
"repository": "https://github.com/libsdl-org/SDL",
"version": "release-3.2.20",
"commit": "96292a5b464258a2b926e0a3d72f8b98c2a81aa6",
"url": "https://codeload.github.com/libsdl-org/SDL/tar.gz/96292a5b464258a2b926e0a3d72f8b98c2a81aa6",
"sha256": "4ae5eba49e7e346f742bea4c9966f4cc871face4b999513fcc1eb1f14d4dec30",
"license": "Zlib"
},
"entt": {
"repository": "https://github.com/skypjack/entt",
"version": "v3.15.0",
"commit": "d4014c74dc3793aba95ae354d6e23a026c2796db",
"url": "https://codeload.github.com/skypjack/entt/tar.gz/d4014c74dc3793aba95ae354d6e23a026c2796db",
"sha256": "9a6c0e1a7049615d40bbe56443d42a27703c6251a5be862de8e952b0f0f84f36",
"license": "MIT"
},
"json": {
"repository": "https://github.com/nlohmann/json",
"version": "v3.12.0",
"commit": "55f93686c01528224f448c19128836e7df245f72",
"url": "https://codeload.github.com/nlohmann/json/tar.gz/55f93686c01528224f448c19128836e7df245f72",
"sha256": "67f4cdd9ca930c9c1e130af4a437c7fc98fab77a2846fc2d2a14b4943831f8ef",
"license": "MIT"
},
"box2d": {
"repository": "https://github.com/erincatto/box2d",
"version": "v3.1.1",
"commit": "8c661469c9507d3ad6fbd2fea3f1aa71669c2fe3",
"url": "https://codeload.github.com/erincatto/box2d/tar.gz/8c661469c9507d3ad6fbd2fea3f1aa71669c2fe3",
"sha256": "dee7bcf0f50b3dcccf67678ce14ffe3ce02583b51b8b03e447ab30a84c3436ef",
"license": "MIT"
},
"box3d": {
"repository": "https://github.com/erincatto/box3d",
"version": "snapshot",
"commit": "f555ee42084e0b43cbffa863f40bff8117c08896",
"url": "https://codeload.github.com/erincatto/box3d/tar.gz/f555ee42084e0b43cbffa863f40bff8117c08896",
"sha256": "75c99b70170172b0dce7a1a6e3d83c29ecabcf3a4ba437b5bb72ee6429529e20",
"license": "MIT"
},
"imgui": {
"repository": "https://github.com/ocornut/imgui",
"version": "v1.92.1",
"commit": "5d4126876bc10396d4c6511853ff10964414c776",
"url": "https://codeload.github.com/ocornut/imgui/tar.gz/5d4126876bc10396d4c6511853ff10964414c776",
"sha256": "0c6f81b3aef443e74dc87131f705b838166dfb7671b662e887f326e61c62cf6c",
"license": "MIT"
},
"cgltf": {
"repository": "https://github.com/jkuhlmann/cgltf",
"version": "v1.15",
"commit": "360db1a95480fe102ae9c69b27c5d101167ff5ba",
"url": "https://codeload.github.com/jkuhlmann/cgltf/tar.gz/360db1a95480fe102ae9c69b27c5d101167ff5ba",
"sha256": "445d135cf793232ae6a585ca1404e4ff28d4f4dbca070689034fe780370ac84a",
"license": "MIT"
},
"stb": {
"repository": "https://github.com/nothings/stb",
"version": "snapshot",
"commit": "2c980bb59875b0d32144a71867fbdebb2f77cd20",
"url": "https://codeload.github.com/nothings/stb/tar.gz/2c980bb59875b0d32144a71867fbdebb2f77cd20",
"sha256": "9a955b1b49a4410088a2e0ee2a9c057c3c907d0c1d75454144cb980aca0ba515",
"license": "MIT OR Unlicense"
}
}
}
+19
View File
@@ -0,0 +1,19 @@
# Editor visual reference
Generated before editor UI implementation with the built-in image generation tool.
This is a design reference, not a screenshot of a working Faset build.
![Editor prototype](editor-prototype.png)
## Implementation direction
- Large central viewport; Scene tree left; property Inspector right; Assets/Console below.
- Near-black flat surfaces, muted separators, readable English text, restrained lavender selection.
- Compact rows and field groups; no decorative cards, glows or gradients.
- Preserve keyboard focus, Unicode text, resize, and clear disabled/error states.
- Implement the controls as retained C++ widgets. The reference bitmap is never used as an interactive UI background.
- Generated sample file sizes/dates, decorative controls and detailed viewport models are illustrative. Only implemented features belong in the actual editor.
## Generation prompt
Use case: ui-mockup. Purpose: visual prototype before implementing Faset Engine, a professional native desktop 2D/3D game editor. One coherent straight-on 16:10 desktop screenshot, high fidelity, no device frame. English UI only. Main user task: edit a small 3D level, select a Door, adjust its Transform and C++ behavior, then Play or Build. Composition: compact top menubar reading 'Faset', 'File', 'Edit', 'Scene', 'View', 'Help'; second modest toolbar with project 'Workshop', scene 'Courtyard', Save, undo/redo, central Play triangle / Stop square and Build on right. Left narrow Scene panel with a readable tree: Courtyard, Camera, Sun, Ground, Player, Door (selected), Crates. Large central perspective viewport taking about 60 percent width, a simple actual engine greybox scene with a floor grid, a modest warm grey rectangular wall and wooden brown door, a couple of plain crates, selection outline and thin XYZ transform gizmo on the door; no impressive photoreal fantasy rendering or claims. Right Inspector about 290 pixels wide, Door name, stacked plain compact component sections Transform with Position X Y Z / Rotation / Scale numeric fields, Mesh with Door.glb asset, Rigid Body with Static, Door Controller with Open angle 90 and Speed 2, Add Component button. Bottom short dock Assets tab and Console tab, Assets breadcrumb 'Assets / Models', understated file list Door.glb, Crate.glb, Ground.material, Courtyard.scene, plus bottom status 'Ready'. Visual direction: quiet dark surfaces like Obsidian and Notion dark mode, flat charcoal blacks #181818 panels, #202020 viewport background, #242424 inputs, subtle single-pixel separators #323232, clear light grey text #d6d6d6, muted secondary #909090, small restrained desaturated lavender-grey selection accent only. Readable modern sans-serif, 13-14px equivalent, compact consistent spacing, normal case panel names, flat functional rows and barely rounded controls. No gradients, glows, glass, cards around every property, marketing headings, decorative dots, invented metrics, huge typography, neon, or decorative dashboard widgets. The viewport content is illustrative; all editor UI must be realistic and implementable with custom retained C++ widgets. Output only this one reference screen.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

+2 -2
View File
@@ -1,8 +1,8 @@
# Faset Engine — архитектура
Редакция 1.1 · 18 сентября 2026 · **принятый проект, реализация ещё не начата**.
Редакция 1.1 · 18 сентября 2026 · **принятый проект, реализация MVP в процессе**.
Этот файл фиксирует решения пользователя. **Принято** означает выбранное направление реализации, а не существующую или проверенную возможность движка. Код Faset Engine ещё не создан, тесты и измерения движка не проводились. Этапы до MVP и после него, зависимости работ и критерии готовности находятся в [PLAN.md](../PLAN.md).
Этот файл фиксирует решения пользователя. **Принято** означает выбранное направление реализации, а не автоматически завершённую возможность движка. Работающий код, выполненные проверки и текущие ограничения перечислены в [журнале реализации](IMPLEMENTATION.md). Этапы до MVP и после него, зависимости работ и критерии готовности находятся в [PLAN.md](../PLAN.md).
Исследовательская база: [Unreal Engine, Godot, Unity и Blender](studies/README.md). Исторические сравнения не отменяют принятые здесь решения. Подробности: [ECS](studies/11-ecs-and-ergonomics.md), [MCP и Blender](studies/12-mcp-and-blender-integration.md), [стек и экспорт](studies/13-build-pipeline-and-stack.md), [renderer](studies/15-renderer-implementation-notes.md), [метаданные](studies/16-native-gameplay-and-metadata.md), [импорт](studies/17-asset-pipeline-and-blender-roundtrip.md), [build/cook](studies/18-build-cook-and-delivery.md).
+1 -1
View File
@@ -1,6 +1,6 @@
# Зависимости, инструменты и независимость
Обновлено 18.09.2026. Здесь перечислены **выбранные направления интеграции**, а не уже подключённые библиотеки движка. Точные версии закрепляются на [M0](../PLAN.md); commits [исследовательского манифеста](studies/source-manifest.json) не заменяют dependency lock.
Обновлено 18.09.2026. Исходные зависимости реализации закреплены в [dependencies.lock.json](../dependencies.lock.json): URL, commit, лицензия и SHA-256 архива. Степень готовности интеграций и проверки отражены в [журнале реализации](IMPLEMENTATION.md); commits [исследовательского манифеста](studies/source-manifest.json) не заменяют dependency lock.
## Runtime и инструменты Faset
+36
View File
@@ -0,0 +1,36 @@
# Implementation checkpoints
This log records working implementation and observed validation. It does not replace
the acceptance criteria in `PLAN.md`. Incomplete platform or workflow checks remain open.
## Checkpoint 1 — native foundation and independent subsystems
Implemented:
- C++20 CMake presets, pinned dependency archives with SHA-256 verification, and separate targets.
- Core persistent IDs, SHA-256, atomic file replacement, JSON IO, and project path boundaries.
- Explicit schema registration, document transactions, revisions, retry keys, Undo/Redo,
recovery records, unknown extension preservation, migrations, and nested template resolution.
- EnTT runtime with real Box2D/Box3D, fixed ticks, deferred changes, checked handles,
interpolation, and statically linked example behaviors.
- GLB/glTF asset import, cooked mesh data, stable source identity, cache generations,
reimport conflicts, cancellation, and the optional Blender export add-on.
- Direct Vulkan renderer, Slang shaders, sprites/meshes, basic PBR and directional
shadows, texture upload, resize, capture, and SDL input.
- English MkDocs/Material manual and an image prototype for the future retained editor UI.
Observed validation on Linux:
- Integrated headless CTest: authoring, runtime, asset pipeline, Blender bundle, core — 5/5 passed.
- Integrated Clang 21.1.8/Ninja build with Vulkan: 7/7 tests passed, including offscreen GPU rendering.
- Independent runtime and asset AddressSanitizer/UndefinedBehaviorSanitizer checks passed.
- Renderer offscreen/visible tests exercised NVIDIA RTX 2080 Ti with Vulkan validation.
- MkDocs strict build passed with MkDocs 1.6.1 and Material 9.7.7.
The full editor, user-project build pipeline, MCP integration, standalone exports,
and Windows acceptance are still being implemented. This checkpoint is not the MVP release.
Known intermediate constraints include box-only physics colliders, root-level physics
objects, static glTF triangles/UV0, a conservative serial renderer, and unfinished
world-preserving authoring reparent operations. These remain implementation work or
explicit profile limits to review during final acceptance.
+3 -1
View File
@@ -1,9 +1,11 @@
# Документация Faset Engine
Актуализировано 18.09.2026 по принятым решениям. Репозиторий пока содержит проектирование и исследования, а не реализацию движка.
Актуализировано 18.09.2026 по принятым решениям. Реализация MVP ведётся параллельно с проверками и пользовательским руководством.
## Канонические документы
- [Manual](manual/index.md) — пользовательское руководство на английском, с приоритетом C++ gameplay и работающих примеров.
- [IMPLEMENTATION.md](IMPLEMENTATION.md) — результаты проверок и ограничения каждого checkpoint.
- [PLAN.md](../PLAN.md) — порядок реализации, критерии приёмки MVP и развитие после него; здесь ведётся статус работ.
- [ARCHITECTURE.md](ARCHITECTURE.md) — принятый стек, контракты и владение данными.
- [DEPENDENCIES.md](DEPENDENCIES.md) — независимость, выбранные зависимости и версии.
+20
View File
@@ -0,0 +1,20 @@
# Keep the manual executable
Document user tasks and actual APIs in English. Explain what each argument means,
which callback it belongs in, and what happens when an object or resource is missing.
Prioritize complete small gameplay examples over isolated declarations. Link examples
to their source files and include them in build or integration checks. When an API
changes, update its examples in the same change.
Mark planned capabilities explicitly. Do not describe a prototype image as a running
editor, a Linux test as Windows validation, or a planned feature as implemented.
Build with strict documentation validation before publishing:
```sh
.cache/docs-venv/bin/python -m mkdocs build --strict
```
Generated output goes to `build/manual`; source Markdown and configuration are tracked
in Git. Research and architecture documents remain separate from this user manual.
+65
View File
@@ -0,0 +1,65 @@
# Build from source
!!! warning "Foundation checkpoint"
These instructions initially cover the build foundation. The integrated editor,
sample projects, and packaging steps are being added and verified during MVP implementation.
## Linux prerequisites
The selected toolchain is C++20, CMake 3.25 or later, Ninja, and Clang.
Graphical builds need Vulkan 1.3 headers/loader and a compatible driver.
SDL3 is built from a pinned source archive.
On Ubuntu, install the native build tools before configuring:
```sh
sudo apt install clang ninja-build cmake python3 python3-venv pkg-config \
libvulkan-dev vulkan-validationlayers libx11-dev libxext-dev libxrandr-dev \
libxcursor-dev libxi-dev libxfixes-dev libxkbcommon-dev libwayland-dev \
libfreetype-dev libharfbuzz-dev xvfb
```
`xvfb` is used for automated window tests. A normal desktop session does not need it.
## Configure, build, test
```sh
python3 tools/fetch_slang.py
cmake --preset linux-debug
cmake --build --preset linux-debug --parallel
ctest --preset linux-debug
```
For an optimized build use `linux-release`. The `linux-sanitize` preset enables
AddressSanitizer and UndefinedBehaviorSanitizer for tests without the graphics backend.
## Dependencies and offline builds
Dependency source URLs, commits, and archive SHA-256 values are stored in
`dependencies.lock.json`. CMake downloads them on the first configuration.
To prefetch them for later offline use:
```sh
python3 tools/fetch_dependencies.py
python3 tools/fetch_dependencies.py --verify-only
```
Cached archives live in `.cache/downloads` and are not committed. Local compilers,
system development libraries, and the Slang compiler must also be available before
disconnecting. Prefetching source archives alone is not a complete offline SDK.
## Windows prerequisites
Use an x64 Visual Studio Developer shell with the Windows SDK, MSVC runtime libraries,
LLVM `clang-cl`, Ninja, CMake, and the Vulkan SDK available. Then use the
`windows-debug` or `windows-release` presets.
```powershell
py tools/fetch_slang.py
cmake --preset windows-debug
cmake --build --preset windows-debug --parallel
ctest --preset windows-debug
```
Windows acceptance is tracked separately from Linux; a successful Linux build does
not verify a Windows build.
+44
View File
@@ -0,0 +1,44 @@
# Faset Engine Manual
Faset is a C++ engine for desktop 2D and 3D games on Linux and Windows.
This manual focuses on writing gameplay: small working examples, the functions they use,
and how those functions interact with scenes, physics, and the editor.
!!! warning "Development status"
MVP implementation is in progress. A planned feature is not a working feature.
Individual guides state their prerequisites and validation status. The current
foundation can be built and tested; a complete editor and game export are not yet available.
Start with [how C++ gameplay works](scripting/index.md), then read
[frame and physics updates](scripting/lifecycle.md). See
[Build from source](getting-started/build.md) for the toolchain and build commands.
The engine, editor, built-in diagnostics, and API identifiers use English.
Your game content and project text can use other languages.
## Learning path
The manual grows alongside tested engine capabilities, in this order:
1. Build and run an example game.
2. Create a C++ behavior and expose a property in the Inspector.
3. Handle input and move a character.
4. Use physics, collision events, and deferred object creation.
5. Work with scene templates, assets, and references.
6. Import from Blender and export a standalone game.
Lua is planned after the C++ foundation. It is not a current scripting option.
## Preview this manual
From the repository root, create a Python virtual environment and install the pinned documentation tools:
```sh
python3 -m venv .cache/docs-venv
.cache/docs-venv/bin/python -m pip install -r docs/requirements.txt
.cache/docs-venv/bin/python -m mkdocs serve
```
On Windows, use `py -m venv .cache/docs-venv` and
`.cache/docs-venv/Scripts/python.exe` in place of the Unix interpreter path.
The manual also remains readable directly as Markdown in the repository.
+41
View File
@@ -0,0 +1,41 @@
# C++ gameplay
In the first version of Faset, a "script" is C++ gameplay code compiled into your game.
It is not an interpreted text file. The gameplay library is statically linked into
a separate Player executable.
The intended iteration cycle is:
1. Stop Play.
2. Edit your C++ behavior or system.
3. Build the changed code and export its property schema.
4. Start a new Player session.
The Editor reads a schema generated by a separate SchemaExporter. It does not load
your gameplay library into its own process. A gameplay crash therefore does not
automatically crash the Editor. Editor native extensions have a different lifecycle
and run inside the Editor process.
!!! note "API examples are added with implementation"
This page describes the accepted execution model. Exact function signatures and
complete examples will be documented alongside compiling runtime examples, rather
than presenting proposed APIs as available functions.
## Behaviors and systems
A behavior gives an individual object lifecycle callbacks. A system operates on a
set of objects with matching components. Both use the same runtime state; the visual
scene and Inspector are the authoring view of that state.
Persistent scene IDs and runtime handles are different. A scene ID survives saving
and reopening. A runtime handle belongs to a particular world/session and can become
invalid after an object is removed. Do not store raw component pointers across
structural changes or treat a runtime handle as a save-file ID.
## Physics ownership
Physics owns the position of a dynamic rigid body. Move it with the supported physics
commands instead of writing its presentation transform. A camera or other visual-only
object can follow the interpolated result without modifying the simulation.
Continue with [Frame and physics updates](lifecycle.md).
+47
View File
@@ -0,0 +1,47 @@
# Frame and physics updates
!!! note "Execution contract"
This page describes the accepted runtime contract. The runnable callback examples
and test results are added as the runtime implementation becomes available.
## Choose the right callback
- `OnStart`: initialize a behavior once its object and components exist.
- `FixedUpdate`: update simulation logic before a physics step.
- `Update`: run frame-based gameplay once per rendered frame.
- `LateUpdate`: update cameras and dependent visual objects after presentation interpolation.
- `OnDestroy`: release subscriptions and other behavior-owned state before its handle is invalidated.
The default simulation interval is 1/60 second. A rendered frame may contain zero,
one, or several fixed ticks. Frame rate and physics rate are not the same quantity.
## Fixed tick order
1. Apply structural commands queued by earlier work.
2. Deliver tick input and call `FixedUpdate`.
3. Apply physics commands and step the 2D and 3D worlds.
4. Read back transforms and queue collision events.
5. Run reactions after physics.
Object creation/removal and component addition/removal are deferred to the beginning
of the next fixed tick. This prevents a callback from invalidating the collection
currently being processed. New objects follow the same initialization rules as objects
loaded from a scene.
After the fixed ticks, the frame runs `Update`, prepares interpolated presentation
transforms, calls `LateUpdate`, and produces the render snapshot.
## Avoid frame-rate-dependent movement
A speed is a distance per second. Multiply it by the callback's elapsed seconds when
calculating a displacement. Do not multiply a velocity by elapsed time before assigning
it to a physics velocity API; the physics step performs that integration.
## Overload and pause
The initial catch-up limit is four fixed ticks per frame. Excess whole intervals are
dropped with a diagnostic rather than making the physics step arbitrarily large.
This is a local-game policy, not a guarantee of deterministic network simulation.
Pausing clears accumulated time. Single-step advances exactly one simulation tick.
Interpolation history is reset for a new session, spawn, or teleport.
+2
View File
@@ -0,0 +1,2 @@
mkdocs==1.6.1
mkdocs-material==9.7.7
+51
View File
@@ -0,0 +1,51 @@
#include "Gameplay.hpp"
#include <algorithm>
#include <cmath>
#include <map>
namespace faset::gameplay {
void registerGameplay(runtime::Runtime& engine) {
runtime::Behavior character;
character.fixedUpdate=[](runtime::Runtime& world,runtime::EntityHandle self,double) {
const auto fields=world.fields(self,"gameplay.character");
auto velocity=world.velocity(self);const auto input=world.input();
velocity[0]=input.horizontal*fields.value("speed",4.0f);
// A minimal demo controller: jump only near zero vertical velocity.
// A production grounded controller needs contact normals / a ground query.
if(input.jumpPressed&&std::abs(velocity[1])<0.1f)velocity[1]=fields.value("jump_speed",5.0f);
world.setVelocity(self,velocity);
};
engine.registerBehavior("gameplay.character",std::move(character));
runtime::Behavior door;
auto open=std::make_shared<std::map<std::pair<std::uint64_t,std::uint32_t>,bool>>();
door.onStart=[open](runtime::Runtime&,runtime::EntityHandle self,double){(*open)[{self.session,self.slot}]=false;};
door.onDestroy=[open](runtime::Runtime&,runtime::EntityHandle self,double){open->erase({self.session,self.slot});};
door.fixedUpdate=[open](runtime::Runtime& world,runtime::EntityHandle self,double dt) {
const auto fields=world.fields(self,"gameplay.door");
auto pose=world.transform(self);
auto& opened=(*open)[{self.session,self.slot}];
if(world.input().interactPressed)opened=!opened;
const float target=opened?fields.value("open_angle",1.5707963f):fields.value("closed_angle",0.0f);
const float distance=target-pose.rotation[1];
const float amount=std::max(0.0f,fields.value("speed",1.5f))*static_cast<float>(dt);
pose.rotation[1]+=std::clamp(distance,-amount,amount);
world.setTransform(self,pose);
};
engine.registerBehavior("gameplay.door",std::move(door));
}
nlohmann::json schema() {
// Explicit declarations shared by Player and SchemaExporter. This function
// constructs descriptions only: no Runtime, physics world or lifecycle.
return nlohmann::json::array({
{{"id","gameplay.character"},{"version",1},{"name","Character"},{"fields",{
{"speed",{{"id","speed"},{"type","number"},{"default",4.0},{"min",0.0}}},
{"jump_speed",{{"id","jump_speed"},{"type","number"},{"default",5.0},{"min",0.0}}}}}},
{{"id","gameplay.door"},{"version",1},{"name","Door"},{"fields",{
{"open_angle",{{"id","open_angle"},{"type","number"},{"default",1.5707963},{"units","rad"}}},
{"closed_angle",{{"id","closed_angle"},{"type","number"},{"default",0.0},{"units","rad"}}},
{"speed",{{"id","speed"},{"type","number"},{"default",1.5},{"min",0.0},{"units","rad/s"}}}}}}
});
}
}
+7
View File
@@ -0,0 +1,7 @@
#pragma once
#include <faset/runtime/Runtime.hpp>
namespace faset::gameplay {
void registerGameplay(runtime::Runtime& runtime);
nlohmann::json schema();
}
+114
View File
@@ -0,0 +1,114 @@
#pragma once
#include <array>
#include <atomic>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <functional>
#include <mutex>
#include <string>
#include <vector>
#include <nlohmann/json.hpp>
namespace faset::assets {
using Json = nlohmann::json;
inline constexpr const char* importer_version = "faset-gltf-1/cgltf-1.15";
struct Vertex {
std::array<float, 3> position{};
std::array<float, 3> normal{0, 0, 1};
std::array<float, 2> uv{};
};
struct Primitive {
std::vector<Vertex> vertices;
std::vector<std::uint32_t> indices;
int material = -1;
};
struct Mesh {
std::string id, name;
std::vector<Primitive> primitives;
};
struct Node {
std::string id, name, parent_id;
int mesh = -1;
// glTF right-handed, Y-up, metres; column-major matrix.
std::array<float, 16> local_transform{1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1};
bool stable_source_id = false;
};
struct Material {
std::string id, name;
std::array<float, 4> base_color{1,1,1,1};
std::array<float, 3> emissive{};
float metallic = 1, roughness = 1, alpha_cutoff = 0.5f;
std::string alpha_mode = "OPAQUE";
bool double_sided = false, unlit = false;
int base_color_texture = -1, metallic_roughness_texture = -1;
int normal_texture = -1, occlusion_texture = -1, emissive_texture = -1;
};
struct Texture {
std::string id, name, mime_type;
// Encoded image bytes, owned by this value. Renderer selects an image decoder.
std::vector<std::byte> bytes;
int wrap_s = 10497, wrap_t = 10497, min_filter = 0, mag_filter = 0;
};
struct CookedAsset {
std::string asset_id, generation;
std::vector<Mesh> meshes;
std::vector<Node> nodes;
std::vector<Material> materials;
std::vector<Texture> textures;
};
struct ImportProgress { float fraction = 0; std::string stage; };
class ImportJob {
public:
using Observer = std::function<void(const ImportProgress&)>;
explicit ImportJob(Observer observer = {});
void cancel() noexcept;
bool cancelled() const noexcept;
ImportProgress progress() const;
void report(float fraction, std::string stage);
private:
std::atomic<bool> cancelled_{false};
mutable std::mutex mutex_;
ImportProgress progress_;
Observer observer_;
};
enum class ImportStatus { succeeded, failed, cancelled, conflict };
struct ImportRequest {
std::filesystem::path source;
std::string asset_id{}; // Empty: restore/create source.faset-import.json identity.
Json settings = nullptr; // Null restores the sidecar recipe; an object replaces it.
// Explicit conflict resolution; false keeps the previous generation active.
bool allow_removed_outputs = false;
};
struct ImportResult {
ImportStatus status = ImportStatus::failed;
std::string asset_id, generation;
std::vector<std::string> diagnostics;
std::vector<std::string> removed_output_ids;
Json manifest;
bool cache_hit = false;
bool ok() const noexcept { return status == ImportStatus::succeeded; }
};
// A pipeline is an authoring service. Player only needs read-only cooked data.
// Writers in one process serialize publication; a cache root has one service owner.
class AssetPipeline {
public:
explicit AssetPipeline(std::filesystem::path cache_root);
ImportResult import_asset(const ImportRequest& request, ImportJob& job);
ImportResult import_asset(const ImportRequest& request);
Json current_manifest(const std::string& asset_id) const;
CookedAsset load_asset(const std::string& asset_id) const;
// Overrides are authoring data beside the source, never generated cache contents.
Json overrides(const std::string& asset_id) const;
void set_overrides(const std::string& asset_id, const Json& overrides);
std::filesystem::path generation_directory(const std::string& asset_id) const;
const std::filesystem::path& cache_root() const noexcept { return cache_root_; }
private:
std::filesystem::path cache_root_;
};
} // namespace faset::assets
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include <faset/core/json.hpp>
#include <faset/core/error.hpp>
#include <map>
#include <string>
#include <type_traits>
namespace faset::authoring {
class SchemaRegistry {
public:
void register_schema(const Json& schema);
void register_schemas(const Json& schemas);
bool contains(const std::string& type) const;
Json schema(const std::string& type) const;
Json manifest() const;
Json default_fields(const std::string& type) const;
// Unknown fields and absent schemas survive authoring. Known fields are validated.
void validate_component(const Json& component) const;
Json migrate_component(const Json& component) const;
void add_migration(const std::string& type, int from_version, Json field_rules);
private:
std::map<std::string,Json> schemas_;
std::map<std::pair<std::string,int>,Json> migrations_;
};
template<class T> class TypeRegistration {
public:
TypeRegistration(SchemaRegistry& registry, std::string id, std::string name, int version=1)
: registry_(registry),schema_{{"id",std::move(id)},{"name",std::move(name)},{"version",version},{"fields",Json::object()}} {}
template<class Value>
TypeRegistration& field(std::string id, std::string name, Value T::*member, Value default_value,
std::string kind, Json constraints=Json::object()) {
static_assert(std::is_member_object_pointer_v<decltype(member)>);
require(!schema_["fields"].contains(id),"schema.duplicate_field","Duplicate stable FieldId");
// Converting the typed default checks supported JSON serialization at compile time.
Json descriptor={{"id",id},{"name",std::move(name)},{"type",std::move(kind)},{"default",Json(default_value)}};
descriptor.update(constraints); schema_["fields"][id]=std::move(descriptor); return *this;
}
void commit() { registry_.register_schema(schema_); }
private:
SchemaRegistry& registry_;
Json schema_;
};
SchemaRegistry builtin_schemas();
void validate_field(const Json& value,const Json& descriptor);
}
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include <faset/authoring/schema.hpp>
#include <filesystem>
#include <map>
#include <mutex>
#include <string>
#include <vector>
namespace faset::authoring {
Json make_scene(std::string name,int dimension=3);
Json make_entity(const SchemaRegistry& schemas,std::string name,const std::string& parent="");
void validate_scene(const Json& scene,const SchemaRegistry& schemas);
class AuthoringService {
public:
explicit AuthoringService(std::filesystem::path project_root,SchemaRegistry schemas=builtin_schemas());
Json create(std::string name,int dimension=3);
Json open(const std::filesystem::path& relative,bool recover=false);
Json query(const std::string& document) const;
Json documents() const;
Json transact(const std::string& document,std::uint64_t expected_revision,const Json& operations,const std::string& idempotency_key="");
Json undo(const std::string& document,std::uint64_t expected_revision);
Json redo(const std::string& document,std::uint64_t expected_revision);
Json save(const std::string& document,const std::filesystem::path& relative={});
Json recovery_documents() const;
const SchemaRegistry& schemas() const {return schemas_;}
void register_schemas(const Json& manifest);
const std::filesystem::path& root() const {return root_;}
private:
struct State {
Json data;
std::uint64_t revision=0;
std::filesystem::path path;
std::string saved_hash,disk_hash;
std::vector<Json> undo,redo;
std::map<std::string,std::pair<std::string,Json>> requests;
};
Json summary(const State& state,bool include_data=true) const;
State& state(const std::string& document);
const State& state(const std::string& document) const;
void journal(const State& state) const;
void apply(Json& scene,const Json& operation);
Json history(const std::string& document,std::uint64_t revision,bool redo);
std::filesystem::path root_;
SchemaRegistry schemas_;
std::map<std::string,State> documents_;
mutable std::recursive_mutex mutex_;
};
}
+11
View File
@@ -0,0 +1,11 @@
#pragma once
#include <faset/authoring/schema.hpp>
#include <functional>
#include <string>
namespace faset::authoring {
struct ResolvedScene {Json scene;Json conflicts=Json::array();};
using SceneLoader=std::function<Json(const std::string&)>;
// Source documents are immutable inputs. Conflicting records stay in the authoring file.
ResolvedScene resolve_templates(const Json& scene,const SchemaRegistry& schemas,const SceneLoader& loader);
}
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include <faset/core/json.hpp>
#include <stdexcept>
#include <string>
namespace faset {
class Error : public std::runtime_error {
public:
Error(std::string code, std::string message, Json details = Json::object())
: std::runtime_error(std::move(message)), code_(std::move(code)), details_(std::move(details)) {}
const std::string& code() const noexcept { return code_; }
Json json() const { return {{"code", code_}, {"message", what()}, {"details", details_}}; }
private:
std::string code_;
Json details_;
};
inline void require(bool condition, const std::string& code, const std::string& message) {
if (!condition) throw Error(code, message);
}
}
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include <cstddef>
#include <filesystem>
#include <span>
#include <string>
#include <string_view>
namespace faset {
std::string sha256(std::span<const std::byte> bytes);
inline std::string sha256(std::string_view text) {
return sha256(std::as_bytes(std::span(text.data(), text.size())));
}
std::string sha256_file(const std::filesystem::path& path);
}
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include <faset/core/json.hpp>
#include <filesystem>
#include <string>
#include <string_view>
namespace faset {
std::string new_id();
std::string read_text(const std::filesystem::path& path);
Json read_json(const std::filesystem::path& path);
void atomic_write(const std::filesystem::path& path, std::string_view bytes);
void atomic_write_json(const std::filesystem::path& path, const Json& value);
// Rejects traversal and symlink escapes before project-scoped file operations.
std::filesystem::path project_path(const std::filesystem::path& root, const std::filesystem::path& relative);
}
+3
View File
@@ -0,0 +1,3 @@
#pragma once
#include <nlohmann/json.hpp>
namespace faset { using Json = nlohmann::json; }
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include <functional>
#include <string>
#include <vector>
namespace faset::render {
// Ordered single-queue graph. Reads must be imported or produced by an earlier pass.
// The Vulkan executor performs barriers at each resource state transition.
class RenderGraph {
public:
using Callback = std::function<void()>;
void import(std::string resource);
void add(std::string name, std::vector<std::string> reads, std::vector<std::string> writes, Callback execute);
void execute() const;
std::vector<std::string> pass_names() const;
private:
struct Pass {std::string name; std::vector<std::string> reads, writes; Callback callback;};
std::vector<std::string> imports_;
std::vector<Pass> passes_;
};
}
+102
View File
@@ -0,0 +1,102 @@
#pragma once
#include <array>
#include <cstdint>
#include <filesystem>
#include <memory>
#include <string>
#include <vector>
namespace faset::render {
using Vec2 = std::array<float, 2>;
using Vec3 = std::array<float, 3>;
using Color = std::array<float, 4>;
using Mat4 = std::array<float, 16>;
inline constexpr Mat4 identity{1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1};
// Matrices are column-major, vectors are columns; clip depth is Vulkan's [0,1].
Mat4 multiply(const Mat4&, const Mat4&);
Mat4 transform(Vec3 position, Vec3 rotation = {}, Vec3 scale = {1,1,1});
Mat4 perspective(float vertical_fov_radians, float aspect, float near_plane, float far_plane);
Mat4 orthographic(float left, float right, float bottom, float top, float near_plane, float far_plane);
Mat4 look_at(Vec3 eye, Vec3 target, Vec3 up = {0,1,0});
struct Vertex { Vec3 position{}; Vec3 normal{0,0,1}; Color color{1,1,1,1}; Vec2 uv{}; };
struct Mesh { std::vector<Vertex> vertices; std::vector<std::uint32_t> indices; };
std::shared_ptr<const Mesh> cube_mesh();
struct Texture;
struct DrawItem {
std::shared_ptr<const Mesh> mesh;
Mat4 model{identity};
Color color{1,1,1,1};
float roughness{0.65f};
float metallic{0.0f};
bool cast_shadow{true};
std::shared_ptr<const Texture> texture;
};
struct Sprite { Vec3 position{}; Vec2 size{1,1}; Color color{1,1,1,1}; float rotation{}; std::shared_ptr<const Texture> texture; };
struct Texture { std::uint32_t width{}, height{}; std::vector<std::uint8_t> rgba; std::uint64_t revision{}; bool srgb{false}; };
struct Quad { float x{}, y{}, width{}, height{}; Color color{1,1,1,1}; std::shared_ptr<const Texture> texture; std::array<float,4> uv_rect{0,0,1,1}; };
struct Text { float x{}, y{}; std::string value; Color color{0.85f,0.87f,0.90f,1}; float size{14}; };
struct Snapshot {
// Optional scene viewport in drawable pixels (x, y, width, height); zero size uses the full target.
std::array<float,4> scene_rect{};
Mat4 view_projection{identity};
Vec3 eye{4,3,5};
Vec3 light_direction{-0.5f,-1,-0.3f};
Color clear_color{0.055f,0.065f,0.085f,1};
std::vector<DrawItem> draws;
std::vector<Sprite> sprites;
// UI coordinates are drawable pixels, top-left origin. Order is preserved per list.
std::vector<Quad> ui_quads;
std::vector<Text> ui_text;
};
struct RendererConfig {
std::uint32_t width{1280}, height{720};
std::string title{"Faset Engine"};
bool headless{false};
bool validation{true};
};
struct Event {
enum class Type { Quit, Resize, FocusGained, FocusLost, MouseMove, MouseDown, MouseUp, Wheel, KeyDown, KeyUp, TextInput, TextEditing };
Type type{};
float x{}, y{};
int button{};
std::string key;
std::string text;
bool control{}, shift{}, alt{}, repeat{};
int edit_start{}, edit_length{};
};
struct FrameStats {
std::uint64_t frame{};
std::uint32_t vertices{}, draw_calls{}, culled_meshes{}, validation_errors{};
double cpu_ms{}, gpu_ms{};
std::string device;
};
class Renderer {
public:
explicit Renderer(const RendererConfig& = {});
~Renderer();
Renderer(Renderer&&) noexcept;
Renderer& operator=(Renderer&&) noexcept;
Renderer(const Renderer&) = delete;
Renderer& operator=(const Renderer&) = delete;
std::vector<Event> poll_events();
void render(const Snapshot&);
void resize(std::uint32_t width, std::uint32_t height);
// Rebuilds graphics pipelines from SPIR-V; a failure preserves the current pipelines.
bool reload_shaders(std::string& error);
// Saves the latest completed frame as a portable RGB PPM image.
void capture(const std::filesystem::path&);
std::vector<std::uint8_t> pixels() const;
std::uint32_t width() const;
std::uint32_t height() const;
bool should_close() const;
const FrameStats& stats() const;
void set_title(const std::string&);
void set_text_input(bool enabled);
void set_text_input_area(float x, float y, float width, float height);
void set_clipboard(const std::string&);
std::string clipboard() const;
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
}
+143
View File
@@ -0,0 +1,143 @@
#pragma once
#include <array>
#include <cstdint>
#include <functional>
#include <memory>
#include <optional>
#include <string>
#include <vector>
#include <nlohmann/json.hpp>
namespace faset::runtime {
using Vec2 = std::array<float, 2>;
using Vec3 = std::array<float, 3>;
using Vec4 = std::array<float, 4>;
struct Transform {
Vec3 position{0, 0, 0};
Vec3 rotation{0, 0, 0}; // Euler XYZ, radians.
Vec3 scale{1, 1, 1};
};
// Process-local identity. Never serialize this into an authoring scene.
struct EntityHandle {
std::uint64_t session{};
std::uint32_t slot{};
std::uint64_t generation{};
explicit operator bool() const noexcept { return session != 0; }
bool operator==(const EntityHandle&) const = default;
};
struct InputState {
float horizontal{};
float vertical{};
bool jumpPressed{};
bool interactPressed{};
};
struct Sprite { Vec4 color{1, 1, 1, 1}; Vec2 size{1, 1}; std::string texture; int layer{}; };
struct Mesh { std::string asset; Vec4 color{1, 1, 1, 1}; std::string primitive{"cube"}; };
struct RenderEntity {
std::string id;
std::string name;
std::optional<std::string> parent;
Transform transform; // Presentation-space local transform; compose parent for rendering.
std::optional<Sprite> sprite;
std::optional<Mesh> mesh;
};
struct RuntimeSnapshot {
int dimension{3};
std::uint64_t tick{};
double alpha{};
std::vector<RenderEntity> entities;
};
struct FrameStats {
unsigned fixedTicks{};
double droppedTime{};
double interpolationAlpha{};
std::uint64_t tick{};
};
struct RuntimeConfig {
double fixedDelta{1.0 / 60.0};
unsigned maxCatchUpTicks{4};
int physicsSubsteps{4};
Vec3 gravity{0, -9.81f, 0};
};
class Runtime;
struct CollisionEvent;
struct Behavior {
using Callback = std::function<void(Runtime&, EntityHandle, double)>;
Callback onStart;
Callback fixedUpdate;
Callback update;
Callback lateUpdate;
Callback onDestroy;
std::function<void(Runtime&, EntityHandle, const CollisionEvent&)> onCollision;
};
struct CollisionEvent {
EntityHandle first;
EntityHandle second;
bool began{};
};
// Single-owner sequential runtime. Gameplay callbacks run on the caller's thread.
// No Editor, MCP, renderer or platform service is linked by this API.
class Runtime {
public:
explicit Runtime(RuntimeConfig config = {});
~Runtime();
Runtime(const Runtime&) = delete;
Runtime& operator=(const Runtime&) = delete;
Runtime(Runtime&&) = delete;
Runtime& operator=(Runtime&&) = delete;
void registerBehavior(std::string componentType, Behavior behavior);
// Validates and prepares a replacement world before destroying the old world.
// Throws a validation/JSON exception on unsupported or invalid scene data.
void load(const nlohmann::json& scene);
void clear();
FrameStats advance(double elapsedSeconds, InputState input = {});
FrameStats singleStep(InputState input = {});
void setPaused(bool paused);
bool paused() const noexcept;
EntityHandle find(const std::string& persistentId) const;
bool valid(EntityHandle handle) const noexcept;
Transform transform(EntityHandle handle) const;
Transform presentation(EntityHandle handle) const;
// Configuration copy. Live poses and velocities have their own typed accessors.
nlohmann::json fields(EntityHandle handle, const std::string& componentType) const;
Vec3 velocity(EntityHandle handle) const;
InputState input() const noexcept;
// Valid until the next fixed tick or scene replacement. No native solver pointers.
const std::vector<CollisionEvent>& collisions() const noexcept;
// Non-physical transforms may be changed in Update/FixedUpdate. Physics poses
// are owned by the solver and require explicit teleport/velocity operations.
void setTransform(EntityHandle handle, const Transform& transform);
void setPresentation(EntityHandle handle, const Transform& transform);
void teleport(EntityHandle handle, const Transform& transform);
void setVelocity(EntityHandle handle, Vec3 velocity);
void applyImpulse(EntityHandle handle, Vec3 impulse);
// All structural changes are applied in FIFO order at the NEXT fixed tick.
// Returned handles, component copies and presentation snapshots are not pointers.
void spawn(nlohmann::json entity);
void destroy(EntityHandle handle);
void addComponent(EntityHandle handle, nlohmann::json component);
void removeComponent(EntityHandle handle, const std::string& componentType);
RuntimeSnapshot snapshot() const;
nlohmann::json snapshotJson() const;
std::uint64_t session() const noexcept;
const std::vector<std::string>& diagnostics() const noexcept;
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
} // namespace faset::runtime
+12
View File
@@ -0,0 +1,12 @@
# Third-party notices
Faset source dependencies are pinned in `dependencies.lock.json`. These notices apply to the named third-party components; they do not assign a license to Faset itself.
- **sdl3** (Zlib): [96292a5b4642](https://github.com/libsdl-org/SDL/tree/96292a5b464258a2b926e0a3d72f8b98c2a81aa6), notice in `sdl3.txt`.
- **entt** (MIT): [d4014c74dc37](https://github.com/skypjack/entt/tree/d4014c74dc3793aba95ae354d6e23a026c2796db), notice in `entt.txt`.
- **box2d** (MIT): [8c661469c950](https://github.com/erincatto/box2d/tree/8c661469c9507d3ad6fbd2fea3f1aa71669c2fe3), notice in `box2d.txt`.
- **box3d** (MIT): [f555ee42084e](https://github.com/erincatto/box3d/tree/f555ee42084e0b43cbffa863f40bff8117c08896), notice in `box3d.txt`.
- **imgui** (MIT): [5d4126876bc1](https://github.com/ocornut/imgui/tree/5d4126876bc10396d4c6511853ff10964414c776), notice in `imgui.txt`.
- **cgltf** (MIT): [360db1a95480](https://github.com/jkuhlmann/cgltf/tree/360db1a95480fe102ae9c69b27c5d101167ff5ba), notice in `cgltf.txt`.
- **stb** (MIT OR Unlicense): [2c980bb59875](https://github.com/nothings/stb/tree/2c980bb59875b0d32144a71867fbdebb2f77cd20), notice in `stb.txt`.
- **nlohmann/json** (MIT): pinned in the dependency lock, notice in `json.txt`.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Erin Catto
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Erin Catto
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+7
View File
@@ -0,0 +1,7 @@
Copyright (c) 2018-2021 Johannes Kuhlmann
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2017-2025 Michele Caini, author of EnTT
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copy of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copy or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014-2025 Omar Cornut
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+9
View File
@@ -0,0 +1,9 @@
MIT License
Copyright (c) <year> <copyright holders>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+18
View File
@@ -0,0 +1,18 @@
Copyright (C) 1997-2025 Sam Lantinga <slouken@libsdl.org>
This software is provided 'as-is', without any express or implied
warranty. In no event will the authors be held liable for any damages
arising from the use of this software.
Permission is granted to anyone to use this software for any purpose,
including commercial applications, and to alter it and redistribute it
freely, subject to the following restrictions:
1. The origin of this software must not be misrepresented; you must not
claim that you wrote the original software. If you use this software
in a product, an acknowledgment in the product documentation would be
appreciated but is not required.
2. Altered source versions must be plainly marked as such, and must not be
misrepresented as being the original software.
3. This notice may not be removed or altered from any source distribution.
+37
View File
@@ -0,0 +1,37 @@
This software is available under 2 licenses -- choose whichever you prefer.
------------------------------------------------------------------------------
ALTERNATIVE A - MIT License
Copyright (c) 2017 Sean Barrett
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
------------------------------------------------------------------------------
ALTERNATIVE B - Public Domain (www.unlicense.org)
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or distribute this
software, either in source code form or as a compiled binary, for any purpose,
commercial or non-commercial, and by any means.
In jurisdictions that recognize copyright laws, the author or authors of this
software dedicate any and all copyright interest in the software to the public
domain. We make this dedication for the benefit of the public at large and to
the detriment of our heirs and successors. We intend this dedication to be an
overt act of relinquishment in perpetuity of all present and future rights to
this software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+35
View File
@@ -0,0 +1,35 @@
site_name: Faset Engine Manual
site_description: Learn C++ gameplay, build scenes, and export games with Faset Engine.
repo_url: https://github.com/emil28092005/Faset_Engine
repo_name: Faset_Engine
docs_dir: docs/manual
site_dir: build/manual
theme:
name: material
language: en
palette:
scheme: slate
primary: black
accent: deep purple
features:
- navigation.sections
- navigation.indexes
- navigation.top
- content.code.copy
- search.suggest
- search.highlight
plugins:
- search
markdown_extensions:
- admonition
- pymdownx.details
- pymdownx.superfences
- pymdownx.highlight:
anchor_linenums: true
nav:
- Start here: index.md
- Build from source: getting-started/build.md
- C++ gameplay:
- How gameplay works: scripting/index.md
- Frame and physics updates: scripting/lifecycle.md
- Contributing to this manual: contributing.md
+63
View File
@@ -0,0 +1,63 @@
struct VertexInput {
float4 clip : POSITION;
float3 world : TEXCOORD0;
float3 normal : NORMAL;
float4 color : COLOR0;
float2 material : TEXCOORD1;
float2 uv : TEXCOORD2;
};
struct VertexOutput {
float4 position : SV_Position;
float3 world : TEXCOORD0;
float3 normal : NORMAL;
float4 color : COLOR0;
float2 material : TEXCOORD1;
float2 uv : TEXCOORD2;
};
struct FrameParameters {
column_major float4x4 lightViewProjection;
float4 lightDirection;
float4 eye;
};
[[vk::push_constant]] ConstantBuffer<FrameParameters> frame;
[[vk::binding(0,0)]] Texture2D<float> shadowMap;
[[vk::binding(1,0)]] SamplerState shadowSampler;
[[vk::binding(2,0)]] Texture2D<float4> colorMap;
[[vk::binding(3,0)]] SamplerState colorSampler;
[shader("vertex")]
VertexOutput vertexMain(VertexInput v) {
VertexOutput o;
o.position=v.clip; o.world=v.world; o.normal=v.normal; o.color=v.color; o.material=v.material; o.uv=v.uv;
return o;
}
[shader("vertex")]
float4 shadowMain(VertexInput v) : SV_Position { return mul(frame.lightViewProjection, float4(v.world,1)); }
[shader("fragment")]
float4 fragmentMain(VertexOutput v) : SV_Target {
float4 base = v.color * colorMap.Sample(colorSampler, v.uv);
if (dot(v.normal,v.normal) < 0.01) return base;
const float pi = 3.14159265;
float3 n=normalize(v.normal), l=normalize(-frame.lightDirection.xyz), view=normalize(frame.eye.xyz-v.world), h=normalize(l+view);
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);
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;
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.rgb,metal), fresnel=f0+(1.0-f0)*pow(1.0-vh,5.0);
float3 spec=d*g*fresnel/max(4.0*nv*nl,0.001);
float4 lightClip=mul(frame.lightViewProjection,float4(v.world,1));
float3 projected=lightClip.xyz/lightClip.w;
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) {
visibility=0.0;
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);
visibility += projected.z-max(0.0008,0.003*(1.0-nl)) <= depth ? 1.0/9.0 : 0.0;
}
}
float3 linear=base.rgb*.12 + ((1.0-fresnel)*(1.0-metal)*base.rgb/pi+spec)*nl*3.0*visibility;
linear=linear/(1.0+linear);
return float4(pow(max(linear,0),float3(1.0/2.2)),base.a);
}
+6
View File
@@ -0,0 +1,6 @@
// Build-time HLSL compatibility fixture: compiled by the same Slang toolchain.
[[vk::binding(0, 0)]] RWStructuredBuffer<float4> outputValues;
[numthreads(1, 1, 1)]
void compatibilityMain(uint3 id : SV_DispatchThreadID) {
outputValues[id.x] = float4(0.25, 0.5, 0.75, 1.0);
}
+388
View File
@@ -0,0 +1,388 @@
#include <faset/assets/asset_pipeline.hpp>
#include <faset/core/hash.hpp>
#include <cgltf.h>
#include <algorithm>
#include <bit>
#include <cmath>
#include <cstring>
#include <fstream>
#include <iomanip>
#include <limits>
#include <map>
#include <memory>
#include <numeric>
#include <random>
#include <set>
#include <sstream>
#include <stdexcept>
#ifdef _WIN32
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <windows.h>
#endif
namespace faset::assets {
namespace {
namespace fs = std::filesystem;
std::mutex writer_mutex;
struct Cancelled {};
void checkpoint(ImportJob& job, float fraction, const std::string& stage) {
job.report(fraction, stage);
if (job.cancelled()) throw Cancelled{};
}
std::string uuid() {
std::random_device random;
std::ostringstream out;
for (int i=0; i<4; ++i) out << std::hex << std::setw(8) << std::setfill('0') << random();
return out.str();
}
void valid_id(const std::string& id) {
if (id.empty() || id.size()>128 || !std::all_of(id.begin(),id.end(),[](unsigned char c){return std::isalnum(c)||c=='-'||c=='_';}))
throw std::runtime_error("Invalid AssetId");
}
std::vector<std::byte> read_bytes(const fs::path& path) {
std::ifstream file(path, std::ios::binary|std::ios::ate);
if (!file) throw std::runtime_error("Cannot read: "+path.string());
const auto length=file.tellg();
if (length<0 || static_cast<std::uint64_t>(length)>1024ull*1024*1024) throw std::runtime_error("Input exceeds 1 GiB limit: "+path.string());
std::vector<std::byte> data(static_cast<std::size_t>(length)); file.seekg(0);
if (!data.empty()&&!file.read(reinterpret_cast<char*>(data.data()),static_cast<std::streamsize>(data.size())))
throw std::runtime_error("Short read: "+path.string());
return data;
}
void write_bytes(const fs::path& path, const std::vector<std::byte>& data) {
fs::create_directories(path.parent_path()); std::ofstream out(path,std::ios::binary|std::ios::trunc);
if (!out || (!data.empty()&&!out.write(reinterpret_cast<const char*>(data.data()),static_cast<std::streamsize>(data.size())))) throw std::runtime_error("Cannot write: "+path.string());
out.close(); if (!out) throw std::runtime_error("Cannot close: "+path.string());
}
Json read_json(const fs::path& path) {
std::ifstream in(path); if(!in)throw std::runtime_error("Cannot read JSON: "+path.string());
return Json::parse(in);
}
void write_json(const fs::path& path,const Json& value) {
const auto text=value.dump(2)+"\n";
write_bytes(path,std::vector<std::byte>(reinterpret_cast<const std::byte*>(text.data()),reinterpret_cast<const std::byte*>(text.data()+text.size())));
}
void atomic_json(const fs::path& path,const Json& value) {
fs::create_directories(path.parent_path()); auto temporary=path; temporary+=".tmp-"+uuid();
try {
write_json(temporary,value);
#ifdef _WIN32
if(!MoveFileExW(temporary.c_str(),path.c_str(),MOVEFILE_REPLACE_EXISTING|MOVEFILE_WRITE_THROUGH)) throw std::runtime_error("Atomic replace failed: "+path.string());
#else
fs::rename(temporary,path);
#endif
} catch(...) { std::error_code ec;fs::remove(temporary,ec);throw; }
}
std::string hash_bytes(const std::vector<std::byte>& bytes) { return faset::sha256(std::span<const std::byte>(bytes)); }
std::string stable_id(const std::string& kind,const std::string& key) { return kind+"-"+faset::sha256(kind+":"+key).substr(0,32); }
std::string safe_name(const char* name) { return name?name:""; }
std::string source_id(const cgltf_extras& extras) {
if(!extras.data)return {};
auto value=Json::parse(extras.data,nullptr,false);
if(value.is_object()&&value.contains("faset_id")&&value["faset_id"].is_string()) return value["faset_id"].get<std::string>();
return {};
}
std::string uri_decode(std::string value) {
std::string result;
for(std::size_t i=0;i<value.size();++i) {
if(value[i]=='%'&&i+2<value.size()) {
const auto hex=value.substr(i+1,2); std::size_t count=0;
const int c=std::stoi(hex,&count,16); if(count!=2||c==0) throw std::runtime_error("Invalid URI escape");
result+=static_cast<char>(c);i+=2;
} else result+=value[i];
}
return result;
}
std::vector<std::byte> decode_data_uri(const std::string& uri) {
const auto comma=uri.find(',');
if(comma==std::string::npos||uri.substr(0,comma).find(";base64")==std::string::npos) throw std::runtime_error("Only base64 data URIs are supported");
constexpr std::string_view alphabet="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::vector<std::byte> data; std::uint32_t bits=0;int count=0;
for(std::size_t i=comma+1;i<uri.size();++i) {
if(uri[i]=='=')break;
const auto v=alphabet.find(uri[i]);if(v==std::string_view::npos)throw std::runtime_error("Invalid base64 image");
bits=(bits<<6)|static_cast<unsigned>(v);count+=6;
if(count>=8){count-=8;data.push_back(static_cast<std::byte>((bits>>count)&255));}
}
return data;
}
fs::path external_path(const fs::path& source,const std::string& uri) {
if(uri.find("://")!=std::string::npos)throw std::runtime_error("Network URI is not an import dependency: "+uri);
const fs::path relative=uri_decode(uri);
if(relative.is_absolute())throw std::runtime_error("glTF URI must be relative");
return (source.parent_path()/relative).lexically_normal();
}
struct Dependency { fs::path path; std::string digest; std::vector<std::byte> bytes; };
using Dependencies=std::map<std::string,Dependency>;
std::vector<std::byte> dependency_bytes(const fs::path& source,const std::string& uri,Dependencies& dependencies) {
if(auto found=dependencies.find(uri);found!=dependencies.end())return found->second.bytes;
auto path=external_path(source,uri);auto bytes=read_bytes(path);dependencies[uri]={path,hash_bytes(bytes),bytes};return bytes;
}
std::string image_mime(const cgltf_image& image,const std::vector<std::byte>& bytes) {
if(image.mime_type)return image.mime_type;
if(bytes.size()>=4&&bytes[0]==std::byte{0x89}&&bytes[1]==std::byte{'P'})return "image/png";
if(bytes.size()>=2&&bytes[0]==std::byte{0xff}&&bytes[1]==std::byte{0xd8})return "image/jpeg";
return "application/octet-stream";
}
std::vector<std::byte> image_bytes(const cgltf_image& image,const fs::path& source,Dependencies& dependencies) {
if(image.uri) {
const std::string uri=image.uri;
return uri.starts_with("data:")?decode_data_uri(uri):dependency_bytes(source,uri,dependencies);
}
if(image.buffer_view&&image.buffer_view->buffer&&image.buffer_view->buffer->data) {
const auto& view=*image.buffer_view;
if(view.offset>view.buffer->size||view.size>view.buffer->size-view.offset)throw std::runtime_error("Image buffer view out of bounds");
const auto* begin=static_cast<const std::byte*>(view.buffer->data)+view.offset;
return {begin,begin+view.size};
}
throw std::runtime_error("Texture has no supported image payload");
}
void put_u32(std::vector<std::byte>& out,std::uint32_t v) {for(int i=0;i<4;++i)out.push_back(static_cast<std::byte>((v>>(8*i))&255));}
void put_float(std::vector<std::byte>& out,float v) {if(!std::isfinite(v))throw std::runtime_error("Non-finite mesh value");put_u32(out,std::bit_cast<std::uint32_t>(v));}
struct BinaryReader {
const std::vector<std::byte>& bytes;std::size_t cursor=0;
std::uint32_t u32(){if(bytes.size()-cursor<4)throw std::runtime_error("Truncated cooked mesh");std::uint32_t v=0;for(int i=0;i<4;++i)v|=std::to_integer<std::uint32_t>(bytes[cursor++])<<(8*i);return v;}
float number(){auto v=std::bit_cast<float>(u32());if(!std::isfinite(v))throw std::runtime_error("Invalid cooked float");return v;}
};
std::vector<std::byte> encode_primitive(const Primitive& p) {
std::vector<std::byte> out;put_u32(out,0x48534d46);put_u32(out,1);put_u32(out,static_cast<std::uint32_t>(p.vertices.size()));put_u32(out,static_cast<std::uint32_t>(p.indices.size()));
for(const auto& v:p.vertices){for(auto f:v.position)put_float(out,f);for(auto f:v.normal)put_float(out,f);for(auto f:v.uv)put_float(out,f);}
for(auto i:p.indices)put_u32(out,i);return out;
}
Primitive decode_primitive(const std::vector<std::byte>& bytes,int material) {
BinaryReader in{bytes};if(in.u32()!=0x48534d46||in.u32()!=1)throw std::runtime_error("Unsupported cooked mesh format");
const auto nv=in.u32(),ni=in.u32();
if(static_cast<std::uint64_t>(nv)*32+static_cast<std::uint64_t>(ni)*4+16!=bytes.size())throw std::runtime_error("Invalid cooked mesh size");
Primitive p;p.material=material;p.vertices.resize(nv);p.indices.resize(ni);
for(auto& v:p.vertices){for(auto& x:v.position)x=in.number();for(auto& x:v.normal)x=in.number();for(auto& x:v.uv)x=in.number();}
for(auto& i:p.indices){i=in.u32();if(i>=nv)throw std::runtime_error("Cooked mesh index out of range");}return p;
}
std::vector<float> unpack(const cgltf_accessor* accessor,std::size_t elements) {
if(!accessor || cgltf_num_components(accessor->type)!=elements)throw std::runtime_error("Unexpected vertex attribute type");
if(accessor->count>10000000)throw std::runtime_error("Mesh exceeds vertex limit");
std::vector<float> values(accessor->count*elements);
if(cgltf_accessor_unpack_floats(accessor,values.data(),values.size())!=values.size())throw std::runtime_error("Cannot unpack vertex attribute");
if(!std::all_of(values.begin(),values.end(),[](float v){return std::isfinite(v);}))throw std::runtime_error("Non-finite vertex attribute");return values;
}
void calculate_normals(Primitive& primitive) {
for(auto& v:primitive.vertices)v.normal={0,0,0};
for(std::size_t i=0;i<primitive.indices.size();i+=3) {
auto& a=primitive.vertices[primitive.indices[i]];auto& b=primitive.vertices[primitive.indices[i+1]];auto& c=primitive.vertices[primitive.indices[i+2]];
std::array<float,3> u{},v{},n{};for(int j=0;j<3;++j){u[j]=b.position[j]-a.position[j];v[j]=c.position[j]-a.position[j];}
n={u[1]*v[2]-u[2]*v[1],u[2]*v[0]-u[0]*v[2],u[0]*v[1]-u[1]*v[0]};
for(auto* vertex:{&a,&b,&c})for(int j=0;j<3;++j)vertex->normal[j]+=n[j];
}
for(auto& v:primitive.vertices){const auto length=std::sqrt(std::inner_product(v.normal.begin(),v.normal.end(),v.normal.begin(),0.f));if(length>1e-12f)for(auto& n:v.normal)n/=length;else v.normal={0,0,1};}
}
int texture_index(const cgltf_texture_view& view,const cgltf_data& data) {
if(!view.texture)return -1;
if(view.texcoord!=0||view.has_transform)throw std::runtime_error("Only TEXCOORD_0 without texture transform is supported by this import profile");
return static_cast<int>(view.texture-data.textures);
}
void add_file(Json& manifest,const fs::path& stage,const std::string& path,const std::vector<std::byte>& bytes) {
write_bytes(stage/path,bytes);manifest["files"].push_back({{"path",path},{"sha256",hash_bytes(bytes)},{"size",bytes.size()}});
}
void validate_generation(const fs::path& directory,const Json& manifest) {
if(manifest.at("schema_version")!=1)throw std::runtime_error("Unsupported asset manifest version");
for(const auto& file:manifest.at("files")) {
const fs::path relative=file.at("path").get<std::string>();
if(relative.is_absolute()||relative.string().find("..")!=std::string::npos)throw std::runtime_error("Invalid cooked file path");
auto bytes=read_bytes(directory/relative);
if(bytes.size()!=file.at("size").get<std::size_t>()||hash_bytes(bytes)!=file.at("sha256").get<std::string>())throw std::runtime_error("Corrupt cooked file: "+relative.string());
}
}
Json material_json(const Material& m) {
return {{"id",m.id},{"name",m.name},{"base_color",m.base_color},{"metallic",m.metallic},{"roughness",m.roughness},{"emissive",m.emissive},{"alpha_mode",m.alpha_mode},{"alpha_cutoff",m.alpha_cutoff},{"double_sided",m.double_sided},{"unlit",m.unlit},{"base_color_texture",m.base_color_texture},{"metallic_roughness_texture",m.metallic_roughness_texture},{"normal_texture",m.normal_texture},{"occlusion_texture",m.occlusion_texture},{"emissive_texture",m.emissive_texture}};
}
}
ImportJob::ImportJob(Observer observer):observer_(std::move(observer)){}
void ImportJob::cancel() noexcept {cancelled_.store(true);}
bool ImportJob::cancelled() const noexcept {return cancelled_.load();}
ImportProgress ImportJob::progress() const {std::lock_guard lock(mutex_);return progress_;}
void ImportJob::report(float fraction,std::string stage) {
ImportProgress progress{fraction,std::move(stage)};{std::lock_guard lock(mutex_);progress_=progress;}
if(observer_) { try { observer_(progress); } catch(...) { /* Observers cannot roll back a published result. */ } }
}
AssetPipeline::AssetPipeline(fs::path root):cache_root_(fs::absolute(std::move(root)).lexically_normal()){}
ImportResult AssetPipeline::import_asset(const ImportRequest& request){ImportJob job;return import_asset(request,job);}
ImportResult AssetPipeline::import_asset(const ImportRequest& request,ImportJob& job) {
std::lock_guard writer(writer_mutex);ImportResult result;fs::path stage;
try {
checkpoint(job,0,"reading source");
const auto logical_source=fs::absolute(request.source).lexically_normal();
auto source=logical_source;
const auto logical_bytes=read_bytes(logical_source);const auto logical_hash=hash_bytes(logical_bytes);
std::string bundle_asset_id;
std::vector<std::byte> payload_snapshot;
if(logical_source.extension()==".json") {
auto bundle=Json::parse(reinterpret_cast<const char*>(logical_bytes.data()),reinterpret_cast<const char*>(logical_bytes.data()+logical_bytes.size()));
if(bundle.at("schema_version")!=1||bundle.at("files").empty())throw std::runtime_error("Invalid bundle manifest");
bundle_asset_id=bundle.at("asset_id").get<std::string>();valid_id(bundle_asset_id);
bool found_payload=false;
for(const auto& file:bundle.at("files")) {
const auto relative=fs::path(file.at("path").get<std::string>());
if(relative.is_absolute()||relative.string().find("..")!=std::string::npos)throw std::runtime_error("Invalid bundle payload path");
const auto candidate=logical_source.parent_path()/relative;const auto bytes=read_bytes(candidate);
if(hash_bytes(bytes)!=file.at("sha256").get<std::string>())throw std::runtime_error("Bundle payload digest mismatch");
if(file.contains("size")&&file.at("size").get<std::size_t>()!=bytes.size())throw std::runtime_error("Bundle payload size mismatch");
if(!found_payload&&relative.extension()==".glb"){source=candidate;payload_snapshot=bytes;found_payload=true;}
}
if(!found_payload)throw std::runtime_error("Bundle has no GLB payload");
}
const auto source_bytes=source==logical_source?logical_bytes:payload_snapshot;const auto source_hash=hash_bytes(source_bytes);
const auto sidecar=fs::path(logical_source.string()+".faset-import.json");
Json metadata=fs::exists(sidecar)?read_json(sidecar):Json::object();
const auto settings=request.settings.is_null()?metadata.value("settings",Json::object()):request.settings;
if(!settings.is_object())throw std::runtime_error("Import settings must be an object");
result.asset_id=request.asset_id.empty()?metadata.value("asset_id",bundle_asset_id.empty()?uuid():bundle_asset_id):request.asset_id;valid_id(result.asset_id);
if(!bundle_asset_id.empty()&&bundle_asset_id!=result.asset_id)throw std::runtime_error("Bundle AssetId disagrees with import identity");
if(metadata.contains("asset_id")&&metadata.at("asset_id")!=result.asset_id)throw std::runtime_error("Explicit AssetId disagrees with source sidecar");
const auto asset_root=cache_root_/"assets"/result.asset_id;
Json previous=fs::exists(asset_root/"current.json")?current_manifest(result.asset_id):Json();
if(!previous.is_null()) {
const auto previous_source=fs::path(previous.at("source").get<std::string>());
if(previous_source!=logical_source&&fs::exists(previous_source))throw std::runtime_error("Duplicate AssetId: previous source still exists");
}
cgltf_options options{};cgltf_data* raw=nullptr;
auto parse=cgltf_parse(&options,source_bytes.data(),source_bytes.size(),&raw);
if(parse!=cgltf_result_success)throw std::runtime_error("Invalid glTF/GLB (parse "+std::to_string(parse)+")");
std::unique_ptr<cgltf_data,decltype(&cgltf_free)> data(raw,cgltf_free);
for(std::size_t i=0;i<data->extensions_required_count;++i)
if(std::string(data->extensions_required[i])!="KHR_materials_unlit")throw std::runtime_error("Unsupported required extension: "+std::string(data->extensions_required[i]));
Dependencies dependencies;
for(std::size_t i=0;i<data->buffers_count;++i) {
const auto* uri=data->buffers[i].uri;
if(uri&&!std::string_view(uri).starts_with("data:")) {
dependency_bytes(source,uri,dependencies);
auto& snapshot=dependencies.at(uri).bytes;
if(snapshot.size()<data->buffers[i].size)throw std::runtime_error("External buffer shorter than declared");
data->buffers[i].data=snapshot.data();
data->buffers[i].data_free_method=cgltf_data_free_method_none;
}
}
if(cgltf_load_buffers(&options,data.get(),source.string().c_str())!=cgltf_result_success)throw std::runtime_error("Cannot load glTF buffers");
if(cgltf_validate(data.get())!=cgltf_result_success)throw std::runtime_error("Invalid glTF buffer/accessor layout");
checkpoint(job,.15f,"extracting geometry");
CookedAsset asset;asset.asset_id=result.asset_id;
std::set<std::string> identifiers;
auto identify=[&](const std::string& kind,const cgltf_extras& extras,const std::string& fallback){
const auto source_identity=source_id(extras);const auto id=stable_id(kind,source_identity.empty()?"fallback:"+fallback:"source:"+source_identity);
if(!identifiers.insert(id).second)throw std::runtime_error("DuplicateSourceId: "+kind+" "+source_identity);
return id;
};
for(std::size_t mi=0;mi<data->meshes_count;++mi) {
checkpoint(job,.15f+.3f*static_cast<float>(mi)/std::max<std::size_t>(1,data->meshes_count),"extracting meshes");
const auto& mesh=data->meshes[mi];Mesh cooked;cooked.name=safe_name(mesh.name);cooked.id=identify("mesh",mesh.extras,std::to_string(mi)+":"+cooked.name);
for(std::size_t pi=0;pi<mesh.primitives_count;++pi) {
const auto& primitive=mesh.primitives[pi];
if(primitive.type!=cgltf_primitive_type_triangles||primitive.has_draco_mesh_compression)throw std::runtime_error("Only uncompressed triangle primitives are supported");
const cgltf_accessor *positions=nullptr,*normals=nullptr,*uv=nullptr;
for(std::size_t ai=0;ai<primitive.attributes_count;++ai){const auto& a=primitive.attributes[ai];if(a.type==cgltf_attribute_type_position)positions=a.data;if(a.type==cgltf_attribute_type_normal)normals=a.data;if(a.type==cgltf_attribute_type_texcoord&&a.index==0)uv=a.data;}
const auto xyz=unpack(positions,3);const auto normal=normals?unpack(normals,3):std::vector<float>{};const auto tex=uv?unpack(uv,2):std::vector<float>{};
const auto count=xyz.size()/3;if((normals&&normal.size()!=count*3)||(uv&&tex.size()!=count*2))throw std::runtime_error("Vertex attribute counts differ");
Primitive output;output.material=primitive.material?static_cast<int>(primitive.material-data->materials):-1;output.vertices.resize(count);
for(std::size_t i=0;i<count;++i){if(i%4096==0&&job.cancelled())throw Cancelled{};std::copy_n(xyz.data()+i*3,3,output.vertices[i].position.begin());if(normals)std::copy_n(normal.data()+i*3,3,output.vertices[i].normal.begin());if(uv)std::copy_n(tex.data()+i*2,2,output.vertices[i].uv.begin());}
if(primitive.indices&&(primitive.indices->is_sparse||primitive.indices->type!=cgltf_type_scalar||
(primitive.indices->component_type!=cgltf_component_type_r_8u&&primitive.indices->component_type!=cgltf_component_type_r_16u&&primitive.indices->component_type!=cgltf_component_type_r_32u)))
throw std::runtime_error("Indices require a dense unsigned integer accessor");
const auto index_count=primitive.indices?primitive.indices->count:count;
if(index_count%3||index_count>30000000)throw std::runtime_error("Invalid triangle index count");
output.indices.resize(index_count);
for(std::size_t i=0;i<index_count;++i){if(i%4096==0&&job.cancelled())throw Cancelled{};const auto index=primitive.indices?cgltf_accessor_read_index(primitive.indices,i):i;if(index>=count)throw std::runtime_error("Index outside vertex array");output.indices[i]=static_cast<std::uint32_t>(index);}
if(!normals)calculate_normals(output);cooked.primitives.push_back(std::move(output));
if(primitive.targets_count)result.diagnostics.push_back("Morph targets imported as static base geometry");
}
asset.meshes.push_back(std::move(cooked));
}
for(std::size_t ni=0;ni<data->nodes_count;++ni) {
const auto& node=data->nodes[ni];Node output;output.name=safe_name(node.name);output.stable_source_id=!source_id(node.extras).empty();
output.id=identify("node",node.extras,std::to_string(ni)+":"+output.name);output.mesh=node.mesh?static_cast<int>(node.mesh-data->meshes):-1;
cgltf_node_transform_local(&node,output.local_transform.data());
if(!std::all_of(output.local_transform.begin(),output.local_transform.end(),[](float v){return std::isfinite(v);}))throw std::runtime_error("Non-finite node transform");
asset.nodes.push_back(std::move(output));if(node.skin)result.diagnostics.push_back("Skinned node imported in static rest pose; animation playback is not cooked");
}
for(std::size_t ni=0;ni<data->nodes_count;++ni)if(data->nodes[ni].parent)asset.nodes[ni].parent_id=asset.nodes[static_cast<std::size_t>(data->nodes[ni].parent-data->nodes)].id;
// Only instantiate the selected/default scene. Unused resources remain reusable outputs.
if(data->scenes_count) {
const auto* selected=data->scene?data->scene:&data->scenes[0];
std::set<std::size_t> active_nodes;
std::function<void(const cgltf_node*)> visit=[&](const cgltf_node* n){
auto index=static_cast<std::size_t>(n-data->nodes);if(!active_nodes.insert(index).second)return;
for(std::size_t i=0;i<n->children_count;++i)visit(n->children[i]);
};
for(std::size_t i=0;i<selected->nodes_count;++i)visit(selected->nodes[i]);
std::vector<Node> active;for(std::size_t i=0;i<asset.nodes.size();++i)if(active_nodes.contains(i))active.push_back(std::move(asset.nodes[i]));asset.nodes=std::move(active);
}
checkpoint(job,.5f,"extracting materials and textures");
for(std::size_t mi=0;mi<data->materials_count;++mi) {
const auto& m=data->materials[mi];Material out;out.name=safe_name(m.name);out.id=identify("material",m.extras,std::to_string(mi)+":"+out.name);
if(m.has_pbr_metallic_roughness){const auto& p=m.pbr_metallic_roughness;std::copy_n(p.base_color_factor,4,out.base_color.begin());out.metallic=p.metallic_factor;out.roughness=p.roughness_factor;out.base_color_texture=texture_index(p.base_color_texture,*data);out.metallic_roughness_texture=texture_index(p.metallic_roughness_texture,*data);}
std::copy_n(m.emissive_factor,3,out.emissive.begin());out.alpha_mode=m.alpha_mode==cgltf_alpha_mode_blend?"BLEND":m.alpha_mode==cgltf_alpha_mode_mask?"MASK":"OPAQUE";out.alpha_cutoff=m.alpha_cutoff;out.double_sided=m.double_sided;out.unlit=m.unlit;
out.normal_texture=texture_index(m.normal_texture,*data);out.occlusion_texture=texture_index(m.occlusion_texture,*data);out.emissive_texture=texture_index(m.emissive_texture,*data);asset.materials.push_back(std::move(out));
}
for(std::size_t ti=0;ti<data->textures_count;++ti) {
const auto& texture=data->textures[ti];if(!texture.image)throw std::runtime_error("Texture extension has no supported fallback image");
Texture out;out.name=safe_name(texture.name);out.id=identify("texture",texture.extras,std::to_string(ti)+":"+out.name);out.bytes=image_bytes(*texture.image,source,dependencies);out.mime_type=image_mime(*texture.image,out.bytes);
if(texture.sampler){out.wrap_s=texture.sampler->wrap_s;out.wrap_t=texture.sampler->wrap_t;out.min_filter=texture.sampler->min_filter;out.mag_filter=texture.sampler->mag_filter;}asset.textures.push_back(std::move(out));
}
Json key{{"source",source_hash},{"settings",settings},{"importer",importer_version},{"dependencies",Json::object()}};
if(source!=logical_source)key["bundle_sha256"]=logical_hash;
for(const auto& [name,item]:dependencies)key["dependencies"][name]=item.digest;
result.generation=faset::sha256(key.dump());asset.generation=result.generation;
Json manifest{{"schema_version",1},{"asset_id",result.asset_id},{"generation",result.generation},{"source",logical_source.string()},{"payload_source",source.string()},{"source_sha256",source_hash},{"importer",importer_version},{"settings",settings},{"input_key",key},{"nodes",Json::array()},{"meshes",Json::array()},{"materials",Json::array()},{"textures",Json::array()},{"files",Json::array()},{"outputs",Json::array()}};
stage=cache_root_/"staging"/uuid();fs::create_directories(stage);
for(const auto& node:asset.nodes){manifest["nodes"].push_back({{"id",node.id},{"name",node.name},{"parent_id",node.parent_id},{"mesh",node.mesh},{"local_transform",node.local_transform},{"stable_source_id",node.stable_source_id}});manifest["outputs"].push_back(node.id);}
for(const auto& mesh:asset.meshes){Json m{{"id",mesh.id},{"name",mesh.name},{"primitives",Json::array()}};for(std::size_t i=0;i<mesh.primitives.size();++i){const auto file="meshes/"+mesh.id+"-"+std::to_string(i)+".fmesh";add_file(manifest,stage,file,encode_primitive(mesh.primitives[i]));m["primitives"].push_back({{"path",file},{"material",mesh.primitives[i].material}});}manifest["meshes"].push_back(m);manifest["outputs"].push_back(mesh.id);}
for(const auto& material:asset.materials){manifest["materials"].push_back(material_json(material));manifest["outputs"].push_back(material.id);}
for(const auto& texture:asset.textures){const auto file="textures/"+texture.id+".image";add_file(manifest,stage,file,texture.bytes);manifest["textures"].push_back({{"id",texture.id},{"name",texture.name},{"mime_type",texture.mime_type},{"path",file},{"wrap_s",texture.wrap_s},{"wrap_t",texture.wrap_t},{"min_filter",texture.min_filter},{"mag_filter",texture.mag_filter}});manifest["outputs"].push_back(texture.id);}
checkpoint(job,.75f,"validating candidate generation");
validate_generation(stage,manifest);
const auto published_ids=manifest.at("outputs").get<std::set<std::string>>();
if(!previous.is_null())for(const auto& old:previous.at("outputs"))if(!published_ids.contains(old.get<std::string>()))result.removed_output_ids.push_back(old.get<std::string>());
result.manifest=manifest;
if(!result.removed_output_ids.empty()&&!request.allow_removed_outputs){result.status=ImportStatus::conflict;result.diagnostics.push_back("Removed or renamed outputs require explicit remap/removal approval; active generation preserved");fs::remove_all(stage);return result;}
if(hash_bytes(read_bytes(source))!=source_hash)throw std::runtime_error("Source changed during import; retry");
if(source!=logical_source&&hash_bytes(read_bytes(logical_source))!=logical_hash)throw std::runtime_error("Bundle manifest changed during import; retry");
for(const auto& [name,item]:dependencies)if(hash_bytes(read_bytes(item.path))!=item.digest)throw std::runtime_error("Dependency changed during import: "+name);
write_json(stage/"manifest.json",manifest);
checkpoint(job,.9f,"publishing generation");
const auto destination=asset_root/"generations"/result.generation;fs::create_directories(destination.parent_path());
if(fs::exists(destination)){auto existing=read_json(destination/"manifest.json");validate_generation(destination,existing);if(existing.at("input_key")!=key)throw std::runtime_error("Digest collision detected");result.cache_hit=true;fs::remove_all(stage);}else fs::rename(stage,destination);
// Persist identity outside the disposable cache, then atomically publish one pointer.
metadata={{"schema_version",1},{"asset_id",result.asset_id},{"settings",settings}};atomic_json(sidecar,metadata);
if(job.cancelled())throw Cancelled{};
atomic_json(asset_root/"current.json",{{"schema_version",1},{"generation",result.generation},{"source",logical_source.string()}});
result.status=ImportStatus::succeeded;job.report(1,"complete");
} catch(const Cancelled&) {result.status=ImportStatus::cancelled;result.diagnostics.push_back("Import cancelled; active generation unchanged");}
catch(const std::exception& e){result.status=ImportStatus::failed;result.diagnostics.push_back(e.what());}
if(!stage.empty()){std::error_code ec;fs::remove_all(stage,ec);}return result;
}
fs::path AssetPipeline::generation_directory(const std::string& id) const {
valid_id(id);const auto root=cache_root_/"assets"/id;const auto pointer=read_json(root/"current.json");const auto generation=pointer.at("generation").get<std::string>();valid_id(generation);return root/"generations"/generation;
}
Json AssetPipeline::current_manifest(const std::string& id) const {
valid_id(id);const auto root=cache_root_/"assets"/id;const auto pointer=read_json(root/"current.json");
const auto generation=pointer.at("generation").get<std::string>();valid_id(generation);
auto manifest=read_json(root/"generations"/generation/"manifest.json");
// One pointer snapshot prevents mixing two concurrently published generations.
manifest["source"]=pointer.at("source");return manifest;
}
CookedAsset AssetPipeline::load_asset(const std::string& id) const {
const auto directory=generation_directory(id);const auto m=read_json(directory/"manifest.json");validate_generation(directory,m);
CookedAsset asset;asset.asset_id=m.at("asset_id");asset.generation=m.at("generation");
for(const auto& n:m.at("nodes")){Node node;node.id=n.at("id");node.name=n.at("name");node.parent_id=n.at("parent_id");node.mesh=n.at("mesh");node.local_transform=n.at("local_transform").get<std::array<float,16>>();node.stable_source_id=n.at("stable_source_id");asset.nodes.push_back(std::move(node));}
for(const auto& j:m.at("meshes")){Mesh mesh;mesh.id=j.at("id");mesh.name=j.at("name");for(const auto& primitive:j.at("primitives"))mesh.primitives.push_back(decode_primitive(read_bytes(directory/primitive.at("path").get<std::string>()),primitive.at("material")));asset.meshes.push_back(std::move(mesh));}
for(const auto& j:m.at("materials")){Material material;material.id=j.at("id");material.name=j.at("name");material.base_color=j.at("base_color").get<std::array<float,4>>();material.emissive=j.at("emissive").get<std::array<float,3>>();material.metallic=j.at("metallic");material.roughness=j.at("roughness");material.alpha_mode=j.at("alpha_mode");material.alpha_cutoff=j.at("alpha_cutoff");material.double_sided=j.at("double_sided");material.unlit=j.at("unlit");material.base_color_texture=j.at("base_color_texture");material.metallic_roughness_texture=j.at("metallic_roughness_texture");material.normal_texture=j.at("normal_texture");material.occlusion_texture=j.at("occlusion_texture");material.emissive_texture=j.at("emissive_texture");asset.materials.push_back(std::move(material));}
for(const auto& j:m.at("textures")){Texture texture;texture.id=j.at("id");texture.name=j.at("name");texture.mime_type=j.at("mime_type");texture.bytes=read_bytes(directory/j.at("path").get<std::string>());texture.wrap_s=j.at("wrap_s");texture.wrap_t=j.at("wrap_t");texture.min_filter=j.at("min_filter");texture.mag_filter=j.at("mag_filter");asset.textures.push_back(std::move(texture));}return asset;
}
Json AssetPipeline::overrides(const std::string& id) const {
const auto source=current_manifest(id).at("source").get<std::string>();const auto path=fs::path(source+".faset-overrides.json");return fs::exists(path)?read_json(path):Json::object();
}
void AssetPipeline::set_overrides(const std::string& id,const Json& values) {
if(!values.is_object())throw std::runtime_error("Overrides must be an object keyed by stable output IDs");std::lock_guard lock(writer_mutex);
atomic_json(fs::path(current_manifest(id).at("source").get<std::string>()+".faset-overrides.json"),values);
}
} // namespace faset::assets
+4
View File
@@ -0,0 +1,4 @@
// cgltf v1.15, MIT, commit 360db1a95480fe102ae9c69b27c5d101167ff5ba.
// Source and license are pinned/provided by faset_cgltf.
#define CGLTF_IMPLEMENTATION
#include <cgltf.h>
+120
View File
@@ -0,0 +1,120 @@
#include <faset/authoring/schema.hpp>
#include <array>
#include <cmath>
#include <set>
namespace faset::authoring {
void validate_field(const Json& value,const Json& descriptor) {
const auto kind=descriptor.value("type",std::string("any"));
bool valid=true;
if(kind=="number"||kind=="float") valid=value.is_number()&&std::isfinite(value.get<double>());
else if(kind=="integer"||kind=="int") valid=value.is_number_integer();
else if(kind=="boolean"||kind=="bool") valid=value.is_boolean();
else if(kind=="string"||kind=="asset_ref"||kind=="entity_ref") valid=value.is_string();
else if(kind=="vec2"||kind=="vec3"||kind=="vec4"||kind=="color") {
const auto size=kind=="vec2"?2u:(kind=="vec3"?3u:4u);
valid=value.is_array()&&value.size()==size;
if(valid) for(const auto& entry:value) valid=valid&&entry.is_number()&&std::isfinite(entry.get<double>());
} else if(kind=="array") valid=value.is_array();
else if(kind=="object") valid=value.is_object();
else require(kind=="any","schema.field_type","Unsupported schema field type: "+kind);
require(valid,"validation.field_type","Invalid value for field "+descriptor.value("id",std::string("?"))+" (expected "+kind+")");
if(value.is_number()) {
if(descriptor.contains("min")) require(value.get<double>()>=descriptor["min"].get<double>(),"validation.minimum","Field is below its minimum");
if(descriptor.contains("max")) require(value.get<double>()<=descriptor["max"].get<double>(),"validation.maximum","Field exceeds its maximum");
}
if(descriptor.contains("enum")) {
bool found=false;for(const auto& option:descriptor["enum"])found=found||option==value;
require(found,"validation.enum","Field value is not an allowed choice");
}
}
void SchemaRegistry::register_schema(const Json& value) {
require(value.is_object()&&value.contains("id")&&value["id"].is_string()&&value.contains("fields")&&value["fields"].is_object(),"schema.invalid","Invalid component schema");
Json normalized=value;
const auto id=value.at("id").get<std::string>();
require(!id.empty(),"schema.invalid","TypeId cannot be empty");
require(value.value("version",1)>0,"schema.invalid","Schema version must be positive");
for(auto& [key,field]:normalized["fields"].items()) {
require(field.is_object()&&field.contains("default"),"schema.invalid","Each field requires a typed default");
require(field.value("id",key)==key,"schema.field_id","Field map keys must be stable FieldIds");
field["id"]=key;validate_field(field["default"],field);
}
if(auto found=schemas_.find(id);found!=schemas_.end()) require(found->second==normalized,"schema.duplicate_type","A different schema is already registered for "+id);
schemas_[id]=std::move(normalized);
}
void SchemaRegistry::register_schemas(const Json& values) {
const auto& array=values.is_array()?values:values.at("types");
auto candidate=*this;for(const auto& schema:array)candidate.register_schema(schema);*this=std::move(candidate);
}
bool SchemaRegistry::contains(const std::string& type)const{return schemas_.contains(type);}
Json SchemaRegistry::schema(const std::string& type)const {
const auto found=schemas_.find(type);require(found!=schemas_.end(),"schema.missing","Component schema unavailable: "+type);return found->second;
}
Json SchemaRegistry::manifest()const {Json types=Json::array();for(const auto& [id,type]:schemas_)types.push_back(type);return {{"format","faset.schema"},{"version",1},{"types",types}};}
Json SchemaRegistry::default_fields(const std::string& type)const {Json fields=Json::object();const auto metadata=schema(type);for(const auto& [id,field]:metadata["fields"].items())fields[id]=field["default"];return fields;}
void SchemaRegistry::validate_component(const Json& component)const {
require(component.is_object()&&component.contains("type")&&component["type"].is_string()&&component.contains("fields")&&component["fields"].is_object(),"component.invalid","Invalid component record");
const auto type=component.at("type").get<std::string>();
if(!contains(type))return;
const auto metadata=schema(type);
// Future or missing-module schemas are preserved, not interpreted with the wrong version.
if(component.value("version",1)!=metadata.value("version",1))return;
for(const auto& [id,value]:component["fields"].items())if(metadata["fields"].contains(id))validate_field(value,metadata["fields"][id]);
}
void SchemaRegistry::add_migration(const std::string& type,int from_version,Json rules) {
require(from_version>0&&rules.is_object(),"migration.invalid","Invalid migration");
require(!migrations_.contains({type,from_version}),"migration.duplicate","Migration already exists");
migrations_[{type,from_version}]=std::move(rules);
}
Json SchemaRegistry::migrate_component(const Json& source)const {
Json result=source;const auto type=result.at("type").get<std::string>();
if(!contains(type))return result;
const auto current=schema(type).value("version",1);
auto version=result.value("version",1);
if(version>current)return result;
while(version<current) {
const auto found=migrations_.find({type,version});
require(found!=migrations_.end(),"migration.required","Explicit migration required for "+type);
for(const auto& [field,rule]:found->second.items()) {
if(rule.contains("default")&&!result["fields"].contains(field))result["fields"][field]=rule["default"];
if(rule.contains("scale")&&result["fields"].contains(field)) {
require(result["fields"][field].is_number(),"migration.type","Cannot scale a nonnumeric field");
result["fields"][field]=result["fields"][field].get<double>()*rule["scale"].get<double>();
}
if(rule.value("require_manual",false)&&result["fields"].contains(field))throw Error("migration.manual","Field requires explicit manual migration",{{"type",type},{"field",field}});
}
result["version"]=++version;
}
const auto metadata=schema(type);
for(const auto& [field,descriptor]:metadata["fields"].items())if(!result["fields"].contains(field))result["fields"][field]=descriptor["default"];
validate_component(result);return result;
}
SchemaRegistry builtin_schemas() {
SchemaRegistry registry;
struct Transform {std::array<float,3> position,rotation,scale;};
TypeRegistration<Transform>(registry,"faset.transform","Transform")
.field("position","Position",&Transform::position,std::array<float,3>{0,0,0},"vec3")
.field("rotation","Rotation",&Transform::rotation,std::array<float,3>{0,0,0},"vec3",{{"unit","radians"}})
.field("scale","Scale",&Transform::scale,std::array<float,3>{1,1,1},"vec3").commit();
auto add=[&](std::string id,std::string name,Json fields){registry.register_schema({{"id",id},{"name",name},{"version",1},{"fields",fields}});};
auto field=[](std::string type,Json value){return Json{{"type",type},{"default",value}};};
add("faset.sprite","Sprite",{{"color",field("color",{0.65,0.6,0.85,1.0})},{"size",field("vec2",{1,1})},{"texture",field("asset_ref","")},{"layer",field("integer",0)}});
add("faset.mesh","Mesh",{{"asset",field("asset_ref","")},{"color",field("color",{0.65,0.65,0.68,1.0})},{"primitive",Json{{"type","string"},{"default","cube"},{"enum",{"cube","plane","asset"}}}}});
add("faset.camera","Camera",{{"fov",Json{{"type","number"},{"default",60.0},{"min",1.0},{"max",179.0}}},{"near",Json{{"type","number"},{"default",0.1},{"min",0.001}}},{"far",Json{{"type","number"},{"default",1000.0},{"min",0.01}}}});
add("faset.light","Directional Light",{{"color",field("color",{1,1,1,1})},{"intensity",Json{{"type","number"},{"default",1.0},{"min",0.0}}}});
for(int dimension:{2,3}) {
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};
add("faset.rigid_body_"+std::to_string(dimension)+"d","Rigid Body "+std::to_string(dimension)+"D",{
{"body_type",Json{{"type","string"},{"default","dynamic"},{"enum",{"static","dynamic","kinematic"}}}},
{"half_extents",field(dimension==2?"vec2":"vec3",extents)},
{"linear_velocity",field(dimension==2?"vec2":"vec3",vector)},
{"density",Json{{"type","number"},{"default",1.0},{"min",0.001}}},
{"friction",Json{{"type","number"},{"default",0.5},{"min",0.0}}},
{"restitution",Json{{"type","number"},{"default",0.0},{"min",0.0},{"max",1.0}}},
{"gravity_scale",field("number",1.0)},
{"category_bits",Json{{"type","integer"},{"default",1},{"min",0}}},
{"mask_bits",Json{{"type","integer"},{"default",65535},{"min",0}}}});
}
return registry;
}
}
+201
View File
@@ -0,0 +1,201 @@
#include <faset/authoring/service.hpp>
#include <faset/core/io.hpp>
#include <faset/core/hash.hpp>
#include <algorithm>
#include <cmath>
#include <set>
namespace faset::authoring {
namespace {
Json& entity(Json& scene,const std::string& id) {
for(auto& item:scene["entities"])if(item.at("id")==id)return item;
throw Error("entity.missing","Entity does not exist",{{"entity",id}});
}
Json& component(Json& item,const std::string& id) {
for(auto& value:item["components"])if(value.at("id")==id)return value;
throw Error("component.missing","Component does not exist",{{"component",id}});
}
std::string parent_id(const Json& item) {return item.contains("parent")&&!item["parent"].is_null()?item["parent"].get<std::string>():"";}
void check_revision(std::uint64_t current,std::uint64_t expected) {
if(current!=expected)throw Error("revision.conflict","Document changed since it was read",{{"expected",expected},{"current",current}});
}
bool finite_json(const Json& value) {
if(value.is_number_float())return std::isfinite(value.get<double>());
if(value.is_structured())for(const auto& child:value)if(!finite_json(child))return false;
return true;
}
}
Json make_scene(std::string name,int dimension) {
require(dimension==2||dimension==3,"scene.dimension","Scene dimension must be 2 or 3");
return {{"format","faset.scene"},{"version",1},{"id",new_id()},{"name",std::move(name)},{"dimension",dimension},{"entities",Json::array()},{"instances",Json::array()}};
}
Json make_entity(const SchemaRegistry& schemas,std::string name,const std::string& parent) {
Json transform={{"id",new_id()},{"type","faset.transform"},{"version",1},{"fields",schemas.default_fields("faset.transform")}};
return {{"id",new_id()},{"name",std::move(name)},{"parent",parent.empty()?Json(nullptr):Json(parent)},{"components",Json::array({transform})}};
}
void validate_scene(const Json& scene,const SchemaRegistry& schemas) {
require(scene.is_object()&&scene.value("format",std::string())=="faset.scene","scene.format","Expected a Faset scene");
require(scene.value("version",0)==1,"scene.version","Unsupported scene format version");
require(scene.contains("id")&&scene["id"].is_string()&&!scene["id"].get<std::string>().empty(),"scene.id","Scene requires a stable ID");
require(scene.contains("name")&&scene["name"].is_string(),"scene.name","Scene name must be text");
require(scene.value("dimension",0)==2||scene.value("dimension",0)==3,"scene.dimension","Scene dimension must be 2 or 3");
require(scene.contains("entities")&&scene["entities"].is_array(),"scene.entities","Scene entities must be an array");
require(finite_json(scene),"validation.finite","Scene contains a non-finite number");
std::set<std::string> ids;std::map<std::string,std::string> parents;
auto insert_id=[&](const Json& value) {require(value.is_string()&&!value.get<std::string>().empty(),"id.invalid","ID must be nonempty text");require(ids.insert(value.get<std::string>()).second,"id.duplicate","Duplicate document ID");};
for(const auto& item:scene["entities"]) {
require(item.is_object()&&item.contains("id")&&item.contains("name")&&item["name"].is_string(),"entity.invalid","Invalid entity record");
insert_id(item["id"]);parents[item["id"].get<std::string>()]=parent_id(item);
require(item.contains("components")&&item["components"].is_array(),"entity.components","Entity components must be an array");
std::set<std::string> types;
for(const auto& value:item["components"]) {
require(value.contains("id"),"component.id","Component requires stable ID");insert_id(value["id"]);schemas.validate_component(value);
require(types.insert(value.at("type").get<std::string>()).second,"component.duplicate_type","One component of each type is supported per entity");
}
}
for(const auto& [id,parent]:parents) {
std::set<std::string> visited{id};auto current=parent;
while(!current.empty()) {require(parents.contains(current),"entity.parent_missing","Parent entity is missing");require(visited.insert(current).second,"entity.cycle","Hierarchy contains a cycle");current=parents.at(current);}
}
if(scene.contains("instances")) {
require(scene["instances"].is_array(),"template.instances","Template instances must be an array");
for(const auto& instance:scene["instances"]) {
require(instance.contains("id")&&instance.contains("source")&&instance["source"].is_string(),"template.instance","Invalid template instance");
insert_id(instance["id"]);
}
}
}
AuthoringService::AuthoringService(std::filesystem::path root,SchemaRegistry schemas):root_(std::filesystem::absolute(std::move(root)).lexically_normal()),schemas_(std::move(schemas)) {std::filesystem::create_directories(root_);}
AuthoringService::State& AuthoringService::state(const std::string& id) {auto found=documents_.find(id);require(found!=documents_.end(),"document.missing","Document is not open");return found->second;}
const AuthoringService::State& AuthoringService::state(const std::string& id)const {auto found=documents_.find(id);require(found!=documents_.end(),"document.missing","Document is not open");return found->second;}
Json AuthoringService::summary(const State& value,bool include_data)const {
Json result={{"id",value.data.at("id")},{"name",value.data.at("name")},{"revision",value.revision},{"dirty",sha256(value.data.dump())!=value.saved_hash},{"path",value.path.generic_string()},{"can_undo",!value.undo.empty()},{"can_redo",!value.redo.empty()}};
if(include_data) result["scene"]=value.data;
return result;
}
void AuthoringService::journal(const State& value)const {
atomic_write_json(project_path(root_,std::filesystem::path(".faset/recovery")/(value.data.at("id").get<std::string>()+".json")),{{"format","faset.recovery"},{"version",1},{"path",value.path.generic_string()},{"revision",value.revision},{"saved_hash",value.saved_hash},{"disk_hash",value.disk_hash},{"scene",value.data}});
}
Json AuthoringService::create(std::string name,int dimension) {
std::lock_guard lock(mutex_);State value;value.data=make_scene(std::move(name),dimension);journal(value);
const auto id=value.data["id"].get<std::string>();documents_.emplace(id,std::move(value));return summary(state(id));
}
Json AuthoringService::open(const std::filesystem::path& relative,bool recover) {
std::lock_guard lock(mutex_);auto path=project_path(root_,relative);Json data=read_json(path);validate_scene(data,schemas_);
const auto id=data.at("id").get<std::string>();
if(documents_.contains(id)) {require(state(id).path==relative.lexically_normal(),"document.id_collision","Another open file has the same document ID");return summary(state(id));}
State value;value.data=data;value.path=relative.lexically_normal();value.saved_hash=sha256(data.dump());value.disk_hash=sha256_file(path);
const auto recovery=project_path(root_,std::filesystem::path(".faset/recovery")/(id+".json"));
if(recover&&std::filesystem::exists(recovery)) {
const auto recovered=read_json(recovery);
require(recovered.value("disk_hash",std::string())==value.disk_hash,"recovery.disk_conflict","Scene file changed since recovery was written");
validate_scene(recovered.at("scene"),schemas_);value.data=recovered.at("scene");value.revision=recovered.value("revision",0u);
}
for(auto& item:value.data["entities"])for(auto& component:item["components"])component=schemas_.migrate_component(component);
documents_.emplace(id,std::move(value));return summary(state(id));
}
Json AuthoringService::query(const std::string& id)const {std::lock_guard lock(mutex_);return summary(state(id));}
Json AuthoringService::documents()const {std::lock_guard lock(mutex_);Json result=Json::array();for(const auto& [id,value]:documents_)result.push_back(summary(value,false));return result;}
void AuthoringService::register_schemas(const Json& manifest) {std::lock_guard lock(mutex_);schemas_.register_schemas(manifest);}
void AuthoringService::apply(Json& scene,const Json& command) {
require(command.is_object()&&command.contains("op")&&command["op"].is_string(),"command.invalid","Command requires an operation name");
const auto op=command.at("op").get<std::string>();
if(op=="entity.create") {
Json value=command.contains("entity")&&command["entity"].is_object()?command["entity"]:make_entity(schemas_,command.value("name",std::string("Object")),command.value("parent",std::string()));
if(!value.contains("id")) value["id"]=new_id();
scene["entities"].push_back(std::move(value));
} else if(op=="entity.rename") {
entity(scene,command.at("entity").get<std::string>())["name"]=command.at("name");
} else if(op=="entity.delete") {
const auto id=command.at("entity").get<std::string>();entity(scene,id);
std::set<std::string> removed{id};bool changed=true;
while(changed) {changed=false;for(const auto& item:scene["entities"])if(removed.contains(parent_id(item)))changed=removed.insert(item.at("id").get<std::string>()).second||changed;}
auto& values=scene["entities"];values.erase(std::remove_if(values.begin(),values.end(),[&](const Json& value){return removed.contains(value.at("id").get<std::string>());}),values.end());
} else if(op=="entity.reparent") {
auto& value=entity(scene,command.at("entity").get<std::string>());
require(!command.value("keep_world",false),"transform.unsupported","World-preserving reparent requires the transform resolver");
const auto parent=command.value("parent",Json(nullptr));if(!parent.is_null())entity(scene,parent.get<std::string>());value["parent"]=parent;
} else if(op=="component.add") {
auto& value=entity(scene,command.at("entity").get<std::string>());const auto type=command.at("type").get<std::string>();
const auto metadata=schemas_.schema(type);Json fields=schemas_.default_fields(type);if(command.contains("fields"))fields.update(command["fields"]);
value["components"].push_back({{"id",command.value("id",new_id())},{"type",type},{"version",metadata.value("version",1)},{"fields",fields}});
} else if(op=="component.remove") {
auto& values=entity(scene,command.at("entity").get<std::string>())["components"];const auto id=command.at("component").get<std::string>();
auto found=std::find_if(values.begin(),values.end(),[&](const Json& value){return value.at("id")==id;});require(found!=values.end(),"component.missing","Component does not exist");values.erase(found);
} else if(op=="component.set") {
auto& value=component(entity(scene,command.at("entity").get<std::string>()),command.at("component").get<std::string>());
const auto field=command.at("field").get<std::string>();require(!field.empty(),"field.invalid","FieldId cannot be empty");value["fields"][field]=command.at("value");
} else if(op=="entity.duplicate") {
const auto id=command.at("entity").get<std::string>();entity(scene,id);
std::set<std::string> subtree{id};bool changed=true;
while(changed) {changed=false;for(const auto& item:scene["entities"])if(subtree.contains(parent_id(item)))changed=subtree.insert(item.at("id").get<std::string>()).second||changed;}
std::map<std::string,std::string> mapping;
for(const auto& item:scene["entities"])if(subtree.contains(item.at("id").get<std::string>())) {mapping[item.at("id")]=new_id();for(const auto& component:item["components"])mapping[component.at("id")]=new_id();}
Json duplicates=Json::array();
for(const auto& item:scene["entities"])if(subtree.contains(item.at("id").get<std::string>())) {
auto copy=item;copy["id"]=mapping.at(item.at("id").get<std::string>());const auto parent=parent_id(item);if(mapping.contains(parent))copy["parent"]=mapping.at(parent);
if(item.at("id")==id)copy["name"]=item.at("name").get<std::string>()+" Copy";
for(auto& component:copy["components"]) {
component["id"]=mapping.at(component.at("id").get<std::string>());const auto type=component.at("type").get<std::string>();
if(!schemas_.contains(type))continue;
const auto metadata=schemas_.schema(type);
for(auto& [field,value]:component["fields"].items())if(metadata["fields"].contains(field)&&metadata["fields"][field].value("type",std::string())=="entity_ref"&&value.is_string()&&mapping.contains(value.get<std::string>()))value=mapping.at(value.get<std::string>());
}
duplicates.push_back(std::move(copy));
}
for(auto& value:duplicates)scene["entities"].push_back(std::move(value));
} else if(op=="scene.rename")scene["name"]=command.at("name");
else if(op=="template.instance") {
Json value=command.at("instance");if(!value.contains("id"))value["id"]=new_id();if(!scene.contains("instances"))scene["instances"]=Json::array();scene["instances"].push_back(std::move(value));
} else if(op=="template.override"||op=="template.revert"||op=="template.suppress"||op=="template.add"||op=="template.reparent") {
auto& instances=scene["instances"];const auto id=command.at("instance").get<std::string>();
auto found=std::find_if(instances.begin(),instances.end(),[&](const Json& value){return value.at("id")==id;});require(found!=instances.end(),"template.missing","Instance not found");
const std::string key=op=="template.suppress"?"suppressed":op=="template.add"?"additions":op=="template.reparent"?"reparents":"overrides";
if(!found->contains(key)) (*found)[key]=Json::array();
auto& records=(*found)[key];
if(key=="overrides") {
const auto address=command.at("address");
auto old=std::find_if(records.begin(),records.end(),[&](const Json& value){return value.at("address")==address;});
if(old!=records.end())records.erase(old);
if(op!="template.revert")records.push_back({{"address",address},{"value",command.at("value")}});
} else records.push_back(command.at("value"));
} else throw Error("command.unknown","Unknown authoring command: "+op);
}
Json AuthoringService::transact(const std::string& id,std::uint64_t revision,const Json& operations,const std::string& key) {
std::lock_guard lock(mutex_);auto& current=state(id);require(operations.is_array()&&!operations.empty(),"transaction.empty","Transaction requires an array of operations");
const auto fingerprint=sha256(Json{{"revision",revision},{"operations",operations}}.dump());
if(!key.empty()&&current.requests.contains(key)) {
const auto& request=current.requests.at(key);require(request.first==fingerprint,"idempotency.conflict","Idempotency key was used with another payload");return request.second;
}
check_revision(current.revision,revision);State candidate=current;
for(const auto& operation:operations)apply(candidate.data,operation);
validate_scene(candidate.data,schemas_);
candidate.undo.push_back(current.data);if(candidate.undo.size()>100)candidate.undo.erase(candidate.undo.begin());candidate.redo.clear();++candidate.revision;
journal(candidate);auto result=summary(candidate);
if(!key.empty()) {if(candidate.requests.size()>=256)candidate.requests.erase(candidate.requests.begin());candidate.requests[key]={fingerprint,result};}
current=std::move(candidate);return result;
}
Json AuthoringService::history(const std::string& id,std::uint64_t revision,bool forward) {
std::lock_guard lock(mutex_);auto& current=state(id);check_revision(current.revision,revision);State candidate=current;
auto& source=forward?candidate.redo:candidate.undo;auto& target=forward?candidate.undo:candidate.redo;
require(!source.empty(),"history.empty",forward?"Nothing to redo":"Nothing to undo");target.push_back(candidate.data);candidate.data=source.back();source.pop_back();++candidate.revision;journal(candidate);current=std::move(candidate);return summary(current);
}
Json AuthoringService::undo(const std::string& id,std::uint64_t revision){return history(id,revision,false);}
Json AuthoringService::redo(const std::string& id,std::uint64_t revision){return history(id,revision,true);}
Json AuthoringService::save(const std::string& id,const std::filesystem::path& relative) {
std::lock_guard lock(mutex_);auto& current=state(id);const auto selected=relative.empty()?current.path:relative.lexically_normal();require(!selected.empty(),"save.path","Choose a scene path before saving");
const auto path=project_path(root_,selected);
if(std::filesystem::exists(path)) {
require(selected==current.path&&!current.disk_hash.empty(),"save.exists","Save As will not overwrite another file");
require(sha256_file(path)==current.disk_hash,"save.disk_conflict","File changed outside the Editor; reload or save to another path");
}
atomic_write_json(path,current.data);current.path=selected;current.saved_hash=sha256(current.data.dump());current.disk_hash=sha256_file(path);
journal(current);return summary(current);
}
Json AuthoringService::recovery_documents()const {
std::lock_guard lock(mutex_);Json result=Json::array();const auto path=project_path(root_,".faset/recovery");if(!std::filesystem::exists(path))return result;
for(const auto& entry:std::filesystem::directory_iterator(path))if(entry.is_regular_file()&&entry.path().extension()==".json") {
try {const auto value=read_json(entry.path());result.push_back({{"id",value.at("scene").at("id")},{"name",value.at("scene").at("name")},{"path",value.at("path")},{"dirty",sha256(value.at("scene").dump())!=value.value("saved_hash",std::string())}});}catch(const std::exception&) {result.push_back({{"error","Invalid recovery record"},{"file",entry.path().filename().string()}});}
}return result;
}
}
+99
View File
@@ -0,0 +1,99 @@
#include <faset/authoring/templates.hpp>
#include <faset/authoring/service.hpp>
#include <faset/core/hash.hpp>
#include <algorithm>
#include <set>
namespace faset::authoring {
namespace {
std::string scoped_id(const std::string& root,const Json& path,const std::string& source) {
const auto digest=sha256(Json::array({root,path,source}).dump());
return digest.substr(0,8)+"-"+digest.substr(8,4)+"-5"+digest.substr(13,3)+"-a"+digest.substr(17,3)+"-"+digest.substr(20,12);
}
struct Resolver {
const SchemaRegistry& schemas;
const SceneLoader& loader;
std::string root;
Json conflicts=Json::array();
std::set<std::string> sources;
void conflict(const Json& path,std::string code,const Json& record) {conflicts.push_back({{"instance_path",path},{"code",std::move(code)},{"record",record}});}
Json* target(Json& entities,const Json& path,const Json& address) {
Json full=path;for(const auto& entry:address.value("path",Json::array()))full.push_back(entry);
for(auto& item:entities)if(item.at("origin").at("path")==full&&item.at("origin").at("object")==address.at("object"))return &item;
return nullptr;
}
Json expand(const Json& scene,const Json& path) {
require(path.size()<=32,"template.depth","Maximum template nesting depth exceeded");
validate_scene(scene,schemas);
Json output=Json::array();std::map<std::string,std::string> ids;
for(const auto& item:scene["entities"]) {
const auto id=item.at("id").get<std::string>();ids[id]=path.empty()?id:scoped_id(root,path,id);
for(const auto& component:item["components"]) {const auto cid=component.at("id").get<std::string>();ids[cid]=path.empty()?cid:scoped_id(root,path,cid);}
}
for(const auto& source:scene["entities"]) {
Json item=source;item["id"]=ids.at(source.at("id").get<std::string>());
item["origin"]={{"path",path},{"object",source.at("id")},{"scene",scene.at("id")}};
if(source.contains("parent")&&!source["parent"].is_null())item["parent"]=ids.at(source["parent"].get<std::string>());
for(auto& component:item["components"]) {
const auto source_id=component.at("id").get<std::string>();component["id"]=ids.at(source_id);component["source_id"]=source_id;
const auto type=component.at("type").get<std::string>();if(!schemas.contains(type))continue;
const auto metadata=schemas.schema(type);
for(auto& [field,value]:component["fields"].items())if(metadata["fields"].contains(field)&&metadata["fields"][field].value("type",std::string())=="entity_ref"&&value.is_string()&&ids.contains(value.get<std::string>()))value=ids.at(value.get<std::string>());
}
output.push_back(std::move(item));
}
for(const auto& instance:scene.value("instances",Json::array())) {
Json nested_path=path;nested_path.push_back(instance.at("id"));const auto source_name=instance.at("source").get<std::string>();
Json expanded=Json::array();std::string source_id;
try {
const auto source=loader(source_name);source_id=source.at("id").get<std::string>();
require(sources.insert(source_id).second,"template.cycle","Template source cycle detected");
expanded=expand(source,nested_path);sources.erase(source_id);
} catch(const std::exception& error) {
if(!source_id.empty())sources.erase(source_id);
conflict(nested_path,"template.source_unavailable",{{"source",source_name},{"message",error.what()}});continue;
}
for(const auto& addition:instance.value("additions",Json::array())) {
Json item=addition;const auto id=item.at("id").get<std::string>();item["id"]=scoped_id(root,nested_path,id);
item["origin"]={{"path",nested_path},{"object",id},{"local",true}};
if(item.contains("parent")&&!item["parent"].is_null())item["parent"]=scoped_id(root,nested_path,item["parent"].get<std::string>());
for(auto& component:item["components"]) {const auto cid=component.at("id").get<std::string>();component["source_id"]=cid;component["id"]=scoped_id(root,nested_path,cid);}
expanded.push_back(std::move(item));
}
for(const auto& change:instance.value("overrides",Json::array())) {
const auto& address=change.at("address");auto* item=target(expanded,nested_path,address);
if(!item){conflict(nested_path,"override.object_missing",change);continue;}
auto found=std::find_if((*item)["components"].begin(),(*item)["components"].end(),[&](const Json& value){return value.at("source_id")==address.at("component");});
if(found==(*item)["components"].end()){conflict(nested_path,"override.component_missing",change);continue;}
const auto type=found->at("type").get<std::string>();const auto field=address.at("field").get<std::string>();
if(!schemas.contains(type)||!schemas.schema(type)["fields"].contains(field)){conflict(nested_path,"override.field_unavailable",change);continue;}
try {validate_field(change.at("value"),schemas.schema(type)["fields"][field]);(*found)["fields"][field]=change.at("value");}
catch(const std::exception& error){conflict(nested_path,"override.invalid",{{"change",change},{"message",error.what()}});}
}
std::set<std::string> suppressed;
for(const auto& address:instance.value("suppressed",Json::array())) {
auto* item=target(expanded,nested_path,address);if(item)suppressed.insert(item->at("id").get<std::string>());else conflict(nested_path,"suppression.object_missing",address);
}
bool changed=true;
while(changed) {changed=false;for(const auto& item:expanded)if(item.contains("parent")&&item["parent"].is_string()&&suppressed.contains(item["parent"].get<std::string>()))changed=suppressed.insert(item.at("id").get<std::string>()).second||changed;}
expanded.erase(std::remove_if(expanded.begin(),expanded.end(),[&](const Json& item){return suppressed.contains(item.at("id").get<std::string>());}),expanded.end());
for(const auto& reparent:instance.value("reparents",Json::array())) {
auto* item=target(expanded,nested_path,reparent.at("object"));
auto* parent=reparent.at("parent").is_null()?nullptr:target(expanded,nested_path,reparent.at("parent"));
if(!item||(!reparent.at("parent").is_null()&&!parent)){conflict(nested_path,"reparent.target_missing",reparent);continue;}
if(reparent.value("keep_world",false)){conflict(nested_path,"reparent.world_transform_required",reparent);continue;}
(*item)["parent"]=parent?parent->at("id"):Json(nullptr);
}
for(auto& item:expanded)output.push_back(std::move(item));
require(output.size()<=100000,"template.size","Resolved scene exceeds object limit");
}
return output;
}
};
}
ResolvedScene resolve_templates(const Json& scene,const SchemaRegistry& schemas,const SceneLoader& loader) {
Resolver resolver{schemas,loader,scene.at("id").get<std::string>()};resolver.sources.insert(scene.at("id").get<std::string>());
Json output=scene;output["entities"]=resolver.expand(scene,Json::array());output["instances"]=Json::array();
validate_scene(output,schemas);return {output,resolver.conflicts};
}
}
+72
View File
@@ -0,0 +1,72 @@
#include <faset/core/hash.hpp>
#include <faset/core/error.hpp>
#include <array>
#include <bit>
#include <cstdint>
#include <fstream>
#include <vector>
namespace faset {
namespace {
constexpr std::array<std::uint32_t,64> constants = {
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2
};
class Digest {
std::array<std::uint32_t,8> state_{0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19};
std::array<std::uint8_t,64> pending_{};
std::uint64_t count_=0;
std::size_t used_=0;
void block() {
std::array<std::uint32_t,64> w{};
for (int i=0;i<16;++i) w[i]=(std::uint32_t(pending_[i*4])<<24)|(std::uint32_t(pending_[i*4+1])<<16)|(std::uint32_t(pending_[i*4+2])<<8)|pending_[i*4+3];
for (int i=16;i<64;++i) {
const auto x=w[i-15],y=w[i-2];
w[i]=w[i-16]+(std::rotr(x,7)^std::rotr(x,18)^(x>>3))+w[i-7]+(std::rotr(y,17)^std::rotr(y,19)^(y>>10));
}
auto a=state_[0],b=state_[1],c=state_[2],d=state_[3],e=state_[4],f=state_[5],g=state_[6],h=state_[7];
for (int i=0;i<64;++i) {
const auto t1=h+(std::rotr(e,6)^std::rotr(e,11)^std::rotr(e,25))+((e&f)^(~e&g))+constants[i]+w[i];
const auto t2=(std::rotr(a,2)^std::rotr(a,13)^std::rotr(a,22))+((a&b)^(a&c)^(b&c));
h=g;g=f;f=e;e=d+t1;d=c;c=b;b=a;a=t1+t2;
}
state_[0]+=a;state_[1]+=b;state_[2]+=c;state_[3]+=d;state_[4]+=e;state_[5]+=f;state_[6]+=g;state_[7]+=h;
}
public:
void update(std::span<const std::byte> bytes) {
count_+=bytes.size();
for (auto byte:bytes) {
pending_[used_++]=std::to_integer<std::uint8_t>(byte);
if (used_==64) { block();used_=0; }
}
}
std::string finish() {
const std::uint64_t bits=count_*8;
pending_[used_++]=0x80;
if (used_>56) { while(used_<64) pending_[used_++]=0;block();used_=0; }
while(used_<56) pending_[used_++]=0;
for (int i=7;i>=0;--i) pending_[used_++]=std::uint8_t(bits>>(i*8));
block();
constexpr char hex[]="0123456789abcdef";
std::string result;result.reserve(64);
for (auto word:state_) for (int i=7;i>=0;--i) result+=hex[(word>>(i*4))&15];
return result;
}
};
}
std::string sha256(std::span<const std::byte> bytes) { Digest digest;digest.update(bytes);return digest.finish(); }
std::string sha256_file(const std::filesystem::path& path) {
std::ifstream stream(path,std::ios::binary);
require(bool(stream),"io.open","Cannot open file for hashing: "+path.string());
Digest digest;std::array<char,65536> buffer{};
while(stream) { stream.read(buffer.data(),buffer.size());digest.update(std::as_bytes(std::span(buffer.data(),static_cast<std::size_t>(stream.gcount())))); }
require(stream.eof(),"io.read","Cannot read file for hashing: "+path.string());
return digest.finish();
}
}
+72
View File
@@ -0,0 +1,72 @@
#include <faset/core/io.hpp>
#include <faset/core/error.hpp>
#include <array>
#include <fstream>
#include <random>
#include <mutex>
#ifdef _WIN32
#define NOMINMAX
#include <windows.h>
#else
#include <fcntl.h>
#include <unistd.h>
#endif
namespace faset {
std::string new_id() {
static std::mutex mutex;
static std::random_device random;
std::array<unsigned char,16> bytes{};
{ std::lock_guard lock(mutex); for(auto& byte:bytes) byte=static_cast<unsigned char>(random()); }
bytes[6]=(bytes[6]&0x0f)|0x40;bytes[8]=(bytes[8]&0x3f)|0x80;
constexpr char hex[]="0123456789abcdef";
std::string result;result.reserve(36);
for(std::size_t i=0;i<bytes.size();++i) { if(i==4||i==6||i==8||i==10)result+='-';result+=hex[bytes[i]>>4];result+=hex[bytes[i]&15]; }
return result;
}
std::string read_text(const std::filesystem::path& path) {
std::ifstream stream(path,std::ios::binary);
require(bool(stream),"io.open","Cannot open file: "+path.string());
std::string value((std::istreambuf_iterator<char>(stream)),{});
require(!stream.bad(),"io.read","Cannot read file: "+path.string());return value;
}
Json read_json(const std::filesystem::path& path) {
try { return Json::parse(read_text(path)); }
catch(const Json::exception& error) { throw Error("format.json","Invalid JSON in "+path.string(),{{"reason",error.what()}}); }
}
void atomic_write(const std::filesystem::path& path,std::string_view bytes) {
const auto parent=path.has_parent_path()?path.parent_path():std::filesystem::path(".");
std::filesystem::create_directories(parent);
const auto temporary=parent/(path.filename().string()+".tmp-"+new_id());
try {
#ifdef _WIN32
HANDLE file=CreateFileW(temporary.c_str(),GENERIC_WRITE,0,nullptr,CREATE_NEW,FILE_ATTRIBUTE_NORMAL,nullptr);
require(file!=INVALID_HANDLE_VALUE,"io.create","Cannot create temporary file");
bool ok=true;std::size_t offset=0;
while(offset<bytes.size()) { DWORD written=0; const auto count=static_cast<DWORD>(std::min<std::size_t>(bytes.size()-offset,1u<<30)); if(!WriteFile(file,bytes.data()+offset,count,&written,nullptr)||written==0){ok=false;break;} offset+=written; }
ok=FlushFileBuffers(file)&&ok;CloseHandle(file);
require(ok,"io.write","Cannot flush temporary file");
require(MoveFileExW(temporary.c_str(),path.c_str(),MOVEFILE_REPLACE_EXISTING|MOVEFILE_WRITE_THROUGH)!=0,"io.replace","Cannot publish file: "+path.string());
#else
const int fd=::open(temporary.c_str(),O_WRONLY|O_CREAT|O_EXCL,0644);
require(fd>=0,"io.create","Cannot create temporary file");
bool ok=true;std::size_t offset=0;
while(offset<bytes.size()) { const auto count=::write(fd,bytes.data()+offset,bytes.size()-offset);if(count<0&&errno==EINTR)continue;if(count<=0){ok=false;break;}offset+=static_cast<std::size_t>(count); }
ok=(::fsync(fd)==0)&&ok;const auto closed=::close(fd);ok=ok&&(closed==0);
require(ok,"io.write","Cannot flush temporary file");
std::filesystem::rename(temporary,path);
const int directory=::open(parent.c_str(),O_RDONLY|O_DIRECTORY);
if(directory>=0){::fsync(directory);::close(directory);}
#endif
} catch(...) { std::error_code ignored;std::filesystem::remove(temporary,ignored);throw; }
}
void atomic_write_json(const std::filesystem::path& path,const Json& value) { atomic_write(path,value.dump(2)+"\n"); }
std::filesystem::path project_path(const std::filesystem::path& root,const std::filesystem::path& relative) {
require(!relative.is_absolute(),"path.outside_project","Expected a path relative to the project");
const auto canonical=std::filesystem::weakly_canonical(root);
const auto target=std::filesystem::weakly_canonical(canonical/relative);
auto a=canonical.begin(),b=target.begin();
for(;a!=canonical.end();++a,++b) require(b!=target.end()&&*a==*b,"path.outside_project","Path escapes the project root");
return target;
}
}
+26
View File
@@ -0,0 +1,26 @@
#include <faset/render/renderer.hpp>
#include <cmath>
#include <stdexcept>
namespace faset::render {
namespace {
Vec3 sub(Vec3 a, Vec3 b) { return {a[0]-b[0],a[1]-b[1],a[2]-b[2]}; }
float dot(Vec3 a,Vec3 b){return a[0]*b[0]+a[1]*b[1]+a[2]*b[2];}
Vec3 cross(Vec3 a,Vec3 b){return {a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]};}
Vec3 unit(Vec3 a){float l=std::sqrt(dot(a,a)); if(l<1e-6f) throw std::invalid_argument("Degenerate camera axis"); return {a[0]/l,a[1]/l,a[2]/l};}
}
Mat4 multiply(const Mat4& a,const Mat4& b){Mat4 r{};for(int c=0;c<4;++c)for(int y=0;y<4;++y)for(int k=0;k<4;++k)r[c*4+y]+=a[k*4+y]*b[c*4+k];return r;}
Mat4 transform(Vec3 p,Vec3 r,Vec3 s){
const float cx=std::cos(r[0]),sx=std::sin(r[0]),cy=std::cos(r[1]),sy=std::sin(r[1]),cz=std::cos(r[2]),sz=std::sin(r[2]);
Mat4 x{1,0,0,0,0,cx,sx,0,0,-sx,cx,0,0,0,0,1};
Mat4 y{cy,0,-sy,0,0,1,0,0,sy,0,cy,0,0,0,0,1};
Mat4 z{cz,sz,0,0,-sz,cz,0,0,0,0,1,0,0,0,0,1};
auto m=multiply(z,multiply(y,x));for(int c=0;c<3;++c)for(int i=0;i<3;++i)m[c*4+i]*=s[c];m[12]=p[0];m[13]=p[1];m[14]=p[2];return m;
}
Mat4 perspective(float fov,float aspect,float n,float f){if(aspect<=0||n<=0||f<=n)throw std::invalid_argument("Invalid perspective volume");float q=1/std::tan(fov*.5f);return {q/aspect,0,0,0,0,-q,0,0,0,0,f/(n-f),-1,0,0,n*f/(n-f),0};}
Mat4 orthographic(float l,float r,float b,float t,float n,float f){if(r==l||t==b||f==n)throw std::invalid_argument("Invalid orthographic volume");return {2/(r-l),0,0,0,0,-2/(t-b),0,0,0,0,1/(n-f),0,-(r+l)/(r-l),(t+b)/(t-b),n/(n-f),1};}
Mat4 look_at(Vec3 e,Vec3 t,Vec3 up){auto f=unit(sub(t,e));auto s=unit(cross(f,up));auto u=cross(s,f);return {s[0],u[0],-f[0],0,s[1],u[1],-f[1],0,s[2],u[2],-f[2],0,-dot(s,e),-dot(u,e),dot(f,e),1};}
std::shared_ptr<const Mesh> cube_mesh(){static auto mesh=[](){auto m=std::make_shared<Mesh>();
const Vec3 normals[]={{0,0,1},{0,0,-1},{1,0,0},{-1,0,0},{0,1,0},{0,-1,0}};
const Vec3 points[][4]={{{-.5f,-.5f,.5f},{.5f,-.5f,.5f},{.5f,.5f,.5f},{-.5f,.5f,.5f}},{{.5f,-.5f,-.5f},{-.5f,-.5f,-.5f},{-.5f,.5f,-.5f},{.5f,.5f,-.5f}},{{.5f,-.5f,.5f},{.5f,-.5f,-.5f},{.5f,.5f,-.5f},{.5f,.5f,.5f}},{{-.5f,-.5f,-.5f},{-.5f,-.5f,.5f},{-.5f,.5f,.5f},{-.5f,.5f,-.5f}},{{-.5f,.5f,.5f},{.5f,.5f,.5f},{.5f,.5f,-.5f},{-.5f,.5f,-.5f}},{{-.5f,-.5f,-.5f},{.5f,-.5f,-.5f},{.5f,-.5f,.5f},{-.5f,-.5f,.5f}}};
for(int face=0;face<6;++face){for(auto p:points[face])m->vertices.push_back({p,normals[face],{1,1,1,1}});for(auto i:{0u,1u,2u,0u,2u,3u})m->indices.push_back(face*4+i);}return m;}();return mesh;}
}
+27
View File
@@ -0,0 +1,27 @@
#include <faset/render/render_graph.hpp>
#include <stdexcept>
#include <unordered_set>
#include <utility>
namespace faset::render {
void RenderGraph::import(std::string resource) { imports_.push_back(std::move(resource)); }
void RenderGraph::add(std::string name, std::vector<std::string> reads, std::vector<std::string> writes, Callback execute) {
if (name.empty() || !execute) throw std::invalid_argument("RenderGraph pass requires a name and callback");
for (const auto& pass : passes_) if (pass.name == name) throw std::invalid_argument("Duplicate RenderGraph pass: " + name);
passes_.push_back({std::move(name),std::move(reads),std::move(writes),std::move(execute)});
}
void RenderGraph::execute() const {
std::unordered_set<std::string> available(imports_.begin(), imports_.end());
// Validate the whole graph before recording any GPU work.
for (const auto& pass : passes_) {
for (const auto& resource : pass.reads)
if (!available.contains(resource)) throw std::runtime_error("RenderGraph pass '" + pass.name + "' reads uninitialized resource '" + resource + "'");
for (const auto& resource : pass.writes) available.insert(resource);
}
for (const auto& pass : passes_) pass.callback();
}
std::vector<std::string> RenderGraph::pass_names() const {
std::vector<std::string> result;
for (const auto& pass : passes_) result.push_back(pass.name);
return result;
}
}
+301
View File
@@ -0,0 +1,301 @@
#include <faset/render/renderer.hpp>
#include <faset/render/render_graph.hpp>
#include <SDL3/SDL.h>
#include <SDL3/SDL_vulkan.h>
#include <vulkan/vulkan.h>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstring>
#include <fstream>
#include <iostream>
#include <limits>
#include <optional>
#include <stdexcept>
#include <unordered_map>
#include <utility>
namespace faset::render {
namespace {
void check(VkResult result,const char* action){if(result!=VK_SUCCESS)throw std::runtime_error(std::string(action)+" failed (Vulkan "+std::to_string(result)+")");}
struct GpuVertex {float clip[4], world[3], normal[3], color[4], material[2], uv[2];};
struct Push {Mat4 light_view_projection; std::array<float,4> light_direction,eye;};
static_assert(sizeof(Push)==96, "Slang FrameParameters layout");
std::array<float,4> point(const Mat4& m,std::array<float,4> p){std::array<float,4> o{};for(int r=0;r<4;++r)for(int c=0;c<4;++c)o[r]+=m[c*4+r]*p[c];return o;}
struct Buffer { VkBuffer handle{}; VkDeviceMemory memory{}; VkDeviceSize size{}; };
struct Image {VkImage handle{}; VkDeviceMemory memory{}; VkImageView view{}; VkImageLayout layout{VK_IMAGE_LAYOUT_UNDEFINED};};
struct Batch {std::uint32_t first{},count{}; const Texture* texture{};};
constexpr std::uint32_t shadow_size=1024;
}
struct Renderer::Impl {
RendererConfig config;
SDL_Window* window{};
bool sdl{},close{},dirty_swapchain{};
std::uint32_t width{},height{};
VkInstance instance{};
VkDebugUtilsMessengerEXT messenger{};
VkSurfaceKHR surface{};
VkPhysicalDevice physical{};
VkDevice device{};
VkQueue queue{};
std::uint32_t queue_family{};
VkCommandPool pool{};
VkCommandBuffer command{};
VkFence fence{};
VkQueryPool timestamp_pool{};
float timestamp_period{};
std::uint32_t timestamp_bits{};
VkSemaphore acquired{},present_ready{};
VkSwapchainKHR swapchain{};
VkFormat swap_format{};
VkExtent2D swap_extent{};
std::vector<VkImage> swap_images;
std::vector<VkImageLayout> swap_layouts;
Image color,depth,shadow;
Buffer vertices,readback;
VkDescriptorSetLayout descriptor_layout{};
VkDescriptorPool descriptor_pool{};
VkSampler shadow_sampler{},color_sampler{};
VkPipelineLayout pipeline_layout{};
VkPipeline pipeline{},ui_pipeline{},shadow_pipeline{};
struct GpuTexture {Image image; VkDescriptorSet descriptor{}; std::shared_ptr<const Texture> source; std::uint64_t revision{};};
std::unordered_map<const Texture*,GpuTexture> textures;
std::shared_ptr<Texture> white;
std::vector<std::uint8_t> last_pixels;
FrameStats statistics;
std::atomic<std::uint32_t> validation_errors{};
~Impl(){cleanup();}
static VKAPI_ATTR VkBool32 VKAPI_CALL debug(VkDebugUtilsMessageSeverityFlagBitsEXT severity,VkDebugUtilsMessageTypeFlagsEXT,const VkDebugUtilsMessengerCallbackDataEXT* data,void* user){
if(severity>=VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT)static_cast<Impl*>(user)->validation_errors.fetch_add(1);
if(severity>=VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT)std::cerr<<"[Vulkan] "<<data->pMessage<<'\n';return VK_FALSE;
}
void destroy(Buffer& b){if(device){if(b.handle)vkDestroyBuffer(device,b.handle,nullptr);if(b.memory)vkFreeMemory(device,b.memory,nullptr);}b={};}
void destroy(Image& i){if(device){if(i.view)vkDestroyImageView(device,i.view,nullptr);if(i.handle)vkDestroyImage(device,i.handle,nullptr);if(i.memory)vkFreeMemory(device,i.memory,nullptr);}i={};}
void cleanup(){
if(device)vkDeviceWaitIdle(device);
for(auto& [_,texture]:textures)destroy(texture.image);
destroy(vertices);destroy(readback);destroy(color);destroy(depth);destroy(shadow);
if(device){
if(pipeline)vkDestroyPipeline(device,pipeline,nullptr);if(ui_pipeline)vkDestroyPipeline(device,ui_pipeline,nullptr);if(shadow_pipeline)vkDestroyPipeline(device,shadow_pipeline,nullptr);
if(pipeline_layout)vkDestroyPipelineLayout(device,pipeline_layout,nullptr);if(descriptor_pool)vkDestroyDescriptorPool(device,descriptor_pool,nullptr);if(descriptor_layout)vkDestroyDescriptorSetLayout(device,descriptor_layout,nullptr);
if(shadow_sampler)vkDestroySampler(device,shadow_sampler,nullptr);if(color_sampler)vkDestroySampler(device,color_sampler,nullptr);
if(swapchain)vkDestroySwapchainKHR(device,swapchain,nullptr);
if(timestamp_pool)vkDestroyQueryPool(device,timestamp_pool,nullptr);
if(acquired)vkDestroySemaphore(device,acquired,nullptr);if(present_ready)vkDestroySemaphore(device,present_ready,nullptr);if(fence)vkDestroyFence(device,fence,nullptr);if(pool)vkDestroyCommandPool(device,pool,nullptr);
vkDestroyDevice(device,nullptr);
}
if(surface)vkDestroySurfaceKHR(instance,surface,nullptr);
if(messenger){auto fn=reinterpret_cast<PFN_vkDestroyDebugUtilsMessengerEXT>(vkGetInstanceProcAddr(instance,"vkDestroyDebugUtilsMessengerEXT"));if(fn)fn(instance,messenger,nullptr);}
if(instance)vkDestroyInstance(instance,nullptr);
if(window)SDL_DestroyWindow(window);if(sdl)SDL_QuitSubSystem(SDL_INIT_VIDEO);
}
std::uint32_t memory_type(std::uint32_t bits,VkMemoryPropertyFlags properties){VkPhysicalDeviceMemoryProperties p{};vkGetPhysicalDeviceMemoryProperties(physical,&p);for(std::uint32_t i=0;i<p.memoryTypeCount;++i)if((bits&(1u<<i))&&(p.memoryTypes[i].propertyFlags&properties)==properties)return i;throw std::runtime_error("Required Vulkan memory type is unavailable");}
Buffer make_buffer(VkDeviceSize bytes,VkBufferUsageFlags usage,VkMemoryPropertyFlags properties){
Buffer b{};b.size=bytes;VkBufferCreateInfo info{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};info.size=bytes;info.usage=usage;info.sharingMode=VK_SHARING_MODE_EXCLUSIVE;
check(vkCreateBuffer(device,&info,nullptr,&b.handle),"Create buffer");
try{VkMemoryRequirements req{};vkGetBufferMemoryRequirements(device,b.handle,&req);VkMemoryAllocateInfo alloc{VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO};alloc.allocationSize=req.size;alloc.memoryTypeIndex=memory_type(req.memoryTypeBits,properties);check(vkAllocateMemory(device,&alloc,nullptr,&b.memory),"Allocate buffer memory");check(vkBindBufferMemory(device,b.handle,b.memory,0),"Bind buffer memory");}catch(...){destroy(b);throw;}return b;
}
Image make_image(std::uint32_t w,std::uint32_t h,VkFormat format,VkImageUsageFlags usage,VkImageAspectFlags aspect){
Image image{};VkImageCreateInfo info{VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO};info.imageType=VK_IMAGE_TYPE_2D;info.format=format;info.extent={w,h,1};info.mipLevels=1;info.arrayLayers=1;info.samples=VK_SAMPLE_COUNT_1_BIT;info.tiling=VK_IMAGE_TILING_OPTIMAL;info.usage=usage;info.sharingMode=VK_SHARING_MODE_EXCLUSIVE;
check(vkCreateImage(device,&info,nullptr,&image.handle),"Create image");
try{VkMemoryRequirements req{};vkGetImageMemoryRequirements(device,image.handle,&req);VkMemoryAllocateInfo alloc{VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO};alloc.allocationSize=req.size;alloc.memoryTypeIndex=memory_type(req.memoryTypeBits,VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);check(vkAllocateMemory(device,&alloc,nullptr,&image.memory),"Allocate image memory");check(vkBindImageMemory(device,image.handle,image.memory,0),"Bind image memory");VkImageViewCreateInfo view{VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO};view.image=image.handle;view.viewType=VK_IMAGE_VIEW_TYPE_2D;view.format=format;view.subresourceRange={aspect,0,1,0,1};check(vkCreateImageView(device,&view,nullptr,&image.view),"Create image view");}catch(...){destroy(image);throw;}return image;
}
void transition(VkCommandBuffer cmd,VkImage image,VkImageLayout& before,VkImageLayout after,VkImageAspectFlags aspect){
// Conservative dependencies make the first single-queue backend auditable.
VkImageMemoryBarrier2 barrier{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER_2};barrier.srcStageMask=before==VK_IMAGE_LAYOUT_UNDEFINED?VK_PIPELINE_STAGE_2_NONE:VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT;barrier.srcAccessMask=before==VK_IMAGE_LAYOUT_UNDEFINED?0:VK_ACCESS_2_MEMORY_WRITE_BIT;barrier.dstStageMask=VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT;barrier.dstAccessMask=VK_ACCESS_2_MEMORY_READ_BIT|VK_ACCESS_2_MEMORY_WRITE_BIT;barrier.oldLayout=before;barrier.newLayout=after;barrier.srcQueueFamilyIndex=barrier.dstQueueFamilyIndex=VK_QUEUE_FAMILY_IGNORED;barrier.image=image;barrier.subresourceRange={aspect,0,1,0,1};VkDependencyInfo dependency{VK_STRUCTURE_TYPE_DEPENDENCY_INFO};dependency.imageMemoryBarrierCount=1;dependency.pImageMemoryBarriers=&barrier;vkCmdPipelineBarrier2(cmd,&dependency);before=after;
}
void transition(VkCommandBuffer cmd,Image& image,VkImageLayout after,VkImageAspectFlags aspect){transition(cmd,image.handle,image.layout,after,aspect);}
void begin(){check(vkResetCommandBuffer(command,0),"Reset command buffer");VkCommandBufferBeginInfo info{VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO};info.flags=VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;check(vkBeginCommandBuffer(command,&info),"Begin command buffer");}
void submit(bool present=false){
check(vkEndCommandBuffer(command),"End command buffer");check(vkResetFences(device,1,&fence),"Reset fence");VkCommandBufferSubmitInfo cmd{VK_STRUCTURE_TYPE_COMMAND_BUFFER_SUBMIT_INFO};cmd.commandBuffer=command;VkSubmitInfo2 info{VK_STRUCTURE_TYPE_SUBMIT_INFO_2};info.commandBufferInfoCount=1;info.pCommandBufferInfos=&cmd;VkSemaphoreSubmitInfo wait{VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO},signal{VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO};if(present){wait.semaphore=acquired;wait.stageMask=VK_PIPELINE_STAGE_2_TRANSFER_BIT;signal.semaphore=present_ready;signal.stageMask=VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT;info.waitSemaphoreInfoCount=1;info.pWaitSemaphoreInfos=&wait;info.signalSemaphoreInfoCount=1;info.pSignalSemaphoreInfos=&signal;}check(vkQueueSubmit2(queue,1,&info,fence),"Submit frame");check(vkWaitForFences(device,1,&fence,VK_TRUE,UINT64_MAX),"Wait frame fence");
}
void initialize(const RendererConfig& c){
config=c;width=c.width;height=c.height;if(!width||!height)throw std::invalid_argument("Renderer dimensions must be nonzero");
std::vector<const char*> extensions;
if(!c.headless){if(!SDL_InitSubSystem(SDL_INIT_VIDEO))throw std::runtime_error(SDL_GetError());sdl=true;window=SDL_CreateWindow(c.title.c_str(),static_cast<int>(width),static_cast<int>(height),SDL_WINDOW_VULKAN|SDL_WINDOW_RESIZABLE|SDL_WINDOW_HIGH_PIXEL_DENSITY);if(!window)throw std::runtime_error(SDL_GetError());Uint32 count{};auto names=SDL_Vulkan_GetInstanceExtensions(&count);if(!names)throw std::runtime_error(SDL_GetError());extensions.assign(names,names+count);SDL_StartTextInput(window);}
std::uint32_t count{};check(vkEnumerateInstanceLayerProperties(&count,nullptr),"Enumerate layers");std::vector<VkLayerProperties> layers(count);check(vkEnumerateInstanceLayerProperties(&count,layers.data()),"Enumerate layers");bool validation=c.validation&&std::any_of(layers.begin(),layers.end(),[](auto& p){return std::strcmp(p.layerName,"VK_LAYER_KHRONOS_validation")==0;});
if(c.validation&&!validation)std::cerr<<"[Faset] Vulkan validation layer not installed; diagnostics disabled.\n";
if(validation)extensions.push_back(VK_EXT_DEBUG_UTILS_EXTENSION_NAME);
VkApplicationInfo app{VK_STRUCTURE_TYPE_APPLICATION_INFO};app.pApplicationName="Faset Engine";app.apiVersion=VK_API_VERSION_1_3;
VkDebugUtilsMessengerCreateInfoEXT debug_info{VK_STRUCTURE_TYPE_DEBUG_UTILS_MESSENGER_CREATE_INFO_EXT};debug_info.messageSeverity=VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT|VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT;debug_info.messageType=VK_DEBUG_UTILS_MESSAGE_TYPE_VALIDATION_BIT_EXT|VK_DEBUG_UTILS_MESSAGE_TYPE_PERFORMANCE_BIT_EXT|VK_DEBUG_UTILS_MESSAGE_TYPE_GENERAL_BIT_EXT;debug_info.pfnUserCallback=debug;debug_info.pUserData=this;
const char* validation_name="VK_LAYER_KHRONOS_validation";VkInstanceCreateInfo info{VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO};info.pApplicationInfo=&app;info.enabledExtensionCount=static_cast<std::uint32_t>(extensions.size());info.ppEnabledExtensionNames=extensions.data();if(validation){info.enabledLayerCount=1;info.ppEnabledLayerNames=&validation_name;info.pNext=&debug_info;}check(vkCreateInstance(&info,nullptr,&instance),"Create Vulkan instance");
if(validation){auto fn=reinterpret_cast<PFN_vkCreateDebugUtilsMessengerEXT>(vkGetInstanceProcAddr(instance,"vkCreateDebugUtilsMessengerEXT"));if(fn)check(fn(instance,&debug_info,nullptr,&messenger),"Create validation messenger");}
if(window&&!SDL_Vulkan_CreateSurface(window,instance,nullptr,&surface))throw std::runtime_error(SDL_GetError());
check(vkEnumeratePhysicalDevices(instance,&count,nullptr),"Enumerate GPUs");std::vector<VkPhysicalDevice> devices(count);check(vkEnumeratePhysicalDevices(instance,&count,devices.data()),"Enumerate GPUs");
int best=-1;
for(auto gpu:devices){VkPhysicalDeviceProperties properties{};vkGetPhysicalDeviceProperties(gpu,&properties);if(properties.apiVersion<VK_API_VERSION_1_3)continue;VkPhysicalDeviceVulkan13Features f13{VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_3_FEATURES};VkPhysicalDeviceFeatures2 features{VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2};features.pNext=&f13;vkGetPhysicalDeviceFeatures2(gpu,&features);if(!f13.synchronization2||!f13.dynamicRendering)continue;
VkFormatProperties color_props{},depth_props{};vkGetPhysicalDeviceFormatProperties(gpu,VK_FORMAT_R8G8B8A8_UNORM,&color_props);vkGetPhysicalDeviceFormatProperties(gpu,VK_FORMAT_D32_SFLOAT,&depth_props);if(!(color_props.optimalTilingFeatures&VK_FORMAT_FEATURE_COLOR_ATTACHMENT_BIT)||!(depth_props.optimalTilingFeatures&VK_FORMAT_FEATURE_DEPTH_STENCIL_ATTACHMENT_BIT)||!(depth_props.optimalTilingFeatures&VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT))continue;
std::uint32_t n{};vkGetPhysicalDeviceQueueFamilyProperties(gpu,&n,nullptr);std::vector<VkQueueFamilyProperties> queues(n);vkGetPhysicalDeviceQueueFamilyProperties(gpu,&n,queues.data());for(std::uint32_t i=0;i<n;++i){VkBool32 supports=VK_TRUE;if(surface)check(vkGetPhysicalDeviceSurfaceSupportKHR(gpu,i,surface,&supports),"Query present support");int score=properties.deviceType==VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU?3:properties.deviceType==VK_PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU?2:1;if(supports&&(queues[i].queueFlags&VK_QUEUE_GRAPHICS_BIT)&&score>best){best=score;physical=gpu;queue_family=i;statistics.device=properties.deviceName;timestamp_period=properties.limits.timestampPeriod;timestamp_bits=queues[i].timestampValidBits;}}
}
if(!physical)throw std::runtime_error("No Vulkan 1.3 device supports dynamic rendering, synchronization2 and required color/depth formats");
float priority=1;VkDeviceQueueCreateInfo qi{VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO};qi.queueFamilyIndex=queue_family;qi.queueCount=1;qi.pQueuePriorities=&priority;VkPhysicalDeviceVulkan13Features f13{VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_3_FEATURES};f13.synchronization2=VK_TRUE;f13.dynamicRendering=VK_TRUE;VkDeviceCreateInfo di{VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO};di.pNext=&f13;di.queueCreateInfoCount=1;di.pQueueCreateInfos=&qi;const char* swap_extension=VK_KHR_SWAPCHAIN_EXTENSION_NAME;if(surface){di.enabledExtensionCount=1;di.ppEnabledExtensionNames=&swap_extension;}check(vkCreateDevice(physical,&di,nullptr,&device),"Create Vulkan device");vkGetDeviceQueue(device,queue_family,0,&queue);
VkCommandPoolCreateInfo pi{VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO};pi.queueFamilyIndex=queue_family;pi.flags=VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT;check(vkCreateCommandPool(device,&pi,nullptr,&pool),"Create command pool");VkCommandBufferAllocateInfo ai{VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO};ai.commandPool=pool;ai.level=VK_COMMAND_BUFFER_LEVEL_PRIMARY;ai.commandBufferCount=1;check(vkAllocateCommandBuffers(device,&ai,&command),"Allocate command buffer");VkFenceCreateInfo fi{VK_STRUCTURE_TYPE_FENCE_CREATE_INFO};fi.flags=VK_FENCE_CREATE_SIGNALED_BIT;check(vkCreateFence(device,&fi,nullptr,&fence),"Create frame fence");VkSemaphoreCreateInfo si{VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO};check(vkCreateSemaphore(device,&si,nullptr,&acquired),"Create acquire semaphore");check(vkCreateSemaphore(device,&si,nullptr,&present_ready),"Create present semaphore");
if(timestamp_bits){VkQueryPoolCreateInfo query{VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO};query.queryType=VK_QUERY_TYPE_TIMESTAMP;query.queryCount=2;check(vkCreateQueryPool(device,&query,nullptr,&timestamp_pool),"Create GPU timestamp queries");}
shadow=make_image(shadow_size,shadow_size,VK_FORMAT_D32_SFLOAT,VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT|VK_IMAGE_USAGE_SAMPLED_BIT,VK_IMAGE_ASPECT_DEPTH_BIT);
make_targets();make_descriptors();make_pipelines();white=std::make_shared<Texture>();white->width=white->height=1;white->rgba={255,255,255,255};upload_texture(white);if(surface)make_swapchain();
}
void make_targets(){
check(vkDeviceWaitIdle(device),"Wait resize");destroy(color);destroy(depth);destroy(readback);
color=make_image(width,height,VK_FORMAT_R8G8B8A8_UNORM,VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT|VK_IMAGE_USAGE_TRANSFER_SRC_BIT,VK_IMAGE_ASPECT_COLOR_BIT);
depth=make_image(width,height,VK_FORMAT_D32_SFLOAT,VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT,VK_IMAGE_ASPECT_DEPTH_BIT);
readback=make_buffer(VkDeviceSize(width)*height*4,VK_BUFFER_USAGE_TRANSFER_DST_BIT,VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT|VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);
last_pixels.clear();
}
void make_swapchain(){
if(!surface)return;int w{},h{};SDL_GetWindowSizeInPixels(window,&w,&h);if(w<=0||h<=0)return;
check(vkDeviceWaitIdle(device),"Wait swapchain");VkSurfaceCapabilitiesKHR caps{};check(vkGetPhysicalDeviceSurfaceCapabilitiesKHR(physical,surface,&caps),"Read surface capabilities");
std::uint32_t count{};check(vkGetPhysicalDeviceSurfaceFormatsKHR(physical,surface,&count,nullptr),"Read surface formats");std::vector<VkSurfaceFormatKHR> formats(count);check(vkGetPhysicalDeviceSurfaceFormatsKHR(physical,surface,&count,formats.data()),"Read surface formats");if(formats.empty())throw std::runtime_error("Window surface has no formats");auto chosen=formats.front();for(auto f:formats)if(f.format==VK_FORMAT_B8G8R8A8_UNORM&&f.colorSpace==VK_COLOR_SPACE_SRGB_NONLINEAR_KHR)chosen=f;
VkFormatProperties properties{};vkGetPhysicalDeviceFormatProperties(physical,chosen.format,&properties);if(!(caps.supportedUsageFlags&VK_IMAGE_USAGE_TRANSFER_DST_BIT)||!(properties.optimalTilingFeatures&VK_FORMAT_FEATURE_BLIT_DST_BIT))throw std::runtime_error("Window surface does not support transfer presentation");
swap_extent=caps.currentExtent;if(swap_extent.width==UINT32_MAX)swap_extent={std::clamp(static_cast<std::uint32_t>(w),caps.minImageExtent.width,caps.maxImageExtent.width),std::clamp(static_cast<std::uint32_t>(h),caps.minImageExtent.height,caps.maxImageExtent.height)};
count=caps.minImageCount+1;if(caps.maxImageCount)count=std::min(count,caps.maxImageCount);VkSwapchainCreateInfoKHR info{VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR};info.surface=surface;info.minImageCount=count;info.imageFormat=chosen.format;info.imageColorSpace=chosen.colorSpace;info.imageExtent=swap_extent;info.imageArrayLayers=1;info.imageUsage=VK_IMAGE_USAGE_TRANSFER_DST_BIT;info.imageSharingMode=VK_SHARING_MODE_EXCLUSIVE;info.preTransform=caps.currentTransform;info.compositeAlpha=VK_COMPOSITE_ALPHA_OPAQUE_BIT_KHR;
if(!(caps.supportedCompositeAlpha&info.compositeAlpha)){for(auto a:{VK_COMPOSITE_ALPHA_PRE_MULTIPLIED_BIT_KHR,VK_COMPOSITE_ALPHA_POST_MULTIPLIED_BIT_KHR,VK_COMPOSITE_ALPHA_INHERIT_BIT_KHR})if(caps.supportedCompositeAlpha&a){info.compositeAlpha=a;break;}}
info.presentMode=VK_PRESENT_MODE_FIFO_KHR;info.clipped=VK_TRUE;info.oldSwapchain=swapchain;VkSwapchainKHR next{};check(vkCreateSwapchainKHR(device,&info,nullptr,&next),"Create swapchain");if(swapchain)vkDestroySwapchainKHR(device,swapchain,nullptr);swapchain=next;swap_format=chosen.format;
check(vkGetSwapchainImagesKHR(device,swapchain,&count,nullptr),"Get swapchain images");swap_images.resize(count);check(vkGetSwapchainImagesKHR(device,swapchain,&count,swap_images.data()),"Get swapchain images");swap_layouts.assign(count,VK_IMAGE_LAYOUT_UNDEFINED);dirty_swapchain=false;
if(width!=swap_extent.width||height!=swap_extent.height){width=swap_extent.width;height=swap_extent.height;make_targets();}
}
void make_descriptors(){
std::array<VkDescriptorSetLayoutBinding,4> bindings{};for(std::uint32_t i=0;i<4;++i){bindings[i].binding=i;bindings[i].descriptorType=i%2?VK_DESCRIPTOR_TYPE_SAMPLER:VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE;bindings[i].descriptorCount=1;bindings[i].stageFlags=VK_SHADER_STAGE_FRAGMENT_BIT;}
VkDescriptorSetLayoutCreateInfo li{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO};li.bindingCount=4;li.pBindings=bindings.data();check(vkCreateDescriptorSetLayout(device,&li,nullptr,&descriptor_layout),"Create descriptor layout");
VkDescriptorPoolSize sizes[]={{VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE,2048},{VK_DESCRIPTOR_TYPE_SAMPLER,2048}};VkDescriptorPoolCreateInfo pi{VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO};pi.flags=VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT;pi.maxSets=1024;pi.poolSizeCount=2;pi.pPoolSizes=sizes;check(vkCreateDescriptorPool(device,&pi,nullptr,&descriptor_pool),"Create descriptor pool");
VkSamplerCreateInfo si{VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO};si.magFilter=si.minFilter=VK_FILTER_NEAREST;si.mipmapMode=VK_SAMPLER_MIPMAP_MODE_NEAREST;si.addressModeU=si.addressModeV=si.addressModeW=VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE;si.maxLod=0;check(vkCreateSampler(device,&si,nullptr,&shadow_sampler),"Create shadow sampler");si.magFilter=si.minFilter=VK_FILTER_LINEAR;check(vkCreateSampler(device,&si,nullptr,&color_sampler),"Create color sampler");
}
VkDescriptorSet upload_texture(std::shared_ptr<const Texture> source){
if(!source)source=white;if(!source||!source->width||!source->height||source->rgba.size()!=std::size_t(source->width)*source->height*4)throw std::invalid_argument("Texture requires width * height * 4 RGBA bytes");
auto found=textures.find(source.get());if(found!=textures.end()&&found->second.revision==source->revision)return found->second.descriptor;
check(vkDeviceWaitIdle(device),"Wait texture upload");GpuTexture texture{};texture.source=source;texture.revision=source->revision;
texture.image=make_image(source->width,source->height,source->srgb?VK_FORMAT_R8G8B8A8_SRGB:VK_FORMAT_R8G8B8A8_UNORM,VK_IMAGE_USAGE_TRANSFER_DST_BIT|VK_IMAGE_USAGE_SAMPLED_BIT,VK_IMAGE_ASPECT_COLOR_BIT);
Buffer staging{};
try{staging=make_buffer(source->rgba.size(),VK_BUFFER_USAGE_TRANSFER_SRC_BIT,VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT|VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);void* mapped{};check(vkMapMemory(device,staging.memory,0,staging.size,0,&mapped),"Map texture staging");std::memcpy(mapped,source->rgba.data(),source->rgba.size());vkUnmapMemory(device,staging.memory);begin();transition(command,texture.image,VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,VK_IMAGE_ASPECT_COLOR_BIT);VkBufferImageCopy copy{};copy.imageSubresource={VK_IMAGE_ASPECT_COLOR_BIT,0,0,1};copy.imageExtent={source->width,source->height,1};vkCmdCopyBufferToImage(command,staging.handle,texture.image.handle,VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,1,&copy);transition(command,texture.image,VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,VK_IMAGE_ASPECT_COLOR_BIT);submit();destroy(staging);
VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO};ai.descriptorPool=descriptor_pool;ai.descriptorSetCount=1;ai.pSetLayouts=&descriptor_layout;check(vkAllocateDescriptorSets(device,&ai,&texture.descriptor),"Allocate texture descriptor");
VkDescriptorImageInfo images[]={{VK_NULL_HANDLE,shadow.view,VK_IMAGE_LAYOUT_DEPTH_READ_ONLY_OPTIMAL},{shadow_sampler,VK_NULL_HANDLE,VK_IMAGE_LAYOUT_UNDEFINED},{VK_NULL_HANDLE,texture.image.view,VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL},{color_sampler,VK_NULL_HANDLE,VK_IMAGE_LAYOUT_UNDEFINED}};
std::array<VkWriteDescriptorSet,4> writes{};for(std::uint32_t i=0;i<4;++i){writes[i]={VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};writes[i].dstSet=texture.descriptor;writes[i].dstBinding=i;writes[i].descriptorCount=1;writes[i].descriptorType=i%2?VK_DESCRIPTOR_TYPE_SAMPLER:VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE;writes[i].pImageInfo=&images[i];}vkUpdateDescriptorSets(device,4,writes.data(),0,nullptr);
}catch(...){destroy(staging);destroy(texture.image);throw;}
if(found!=textures.end()){destroy(found->second.image);vkFreeDescriptorSets(device,descriptor_pool,1,&found->second.descriptor);found->second=std::move(texture);return found->second.descriptor;}
auto [inserted,_]=textures.emplace(source.get(),std::move(texture));return inserted->second.descriptor;
}
VkShaderModule shader(const char* name){
std::vector<std::filesystem::path> roots;const char* base=SDL_GetBasePath();if(base)roots.emplace_back(std::filesystem::path(base)/"shaders");roots.emplace_back(std::filesystem::current_path()/"shaders");roots.emplace_back(FASET_SHADER_DIRECTORY);
std::ifstream file;for(const auto& root:roots){file.open(root/(std::string(name)+".spv"),std::ios::binary|std::ios::ate);if(file)break;file.clear();}if(!file)throw std::runtime_error(std::string("Compiled Slang shader missing: ")+name+".spv");auto size=file.tellg();if(size<=0||size%4!=0)throw std::runtime_error("Invalid SPIR-V byte length");std::vector<std::uint32_t> bytes(static_cast<std::size_t>(size)/4);file.seekg(0);file.read(reinterpret_cast<char*>(bytes.data()),size);VkShaderModuleCreateInfo ci{VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO};ci.codeSize=static_cast<std::size_t>(size);ci.pCode=bytes.data();VkShaderModule result{};check(vkCreateShaderModule(device,&ci,nullptr,&result),"Create shader module");return result;
}
void make_pipelines(){
VkPushConstantRange push{VK_SHADER_STAGE_VERTEX_BIT|VK_SHADER_STAGE_FRAGMENT_BIT,0,sizeof(Push)};VkPipelineLayoutCreateInfo li{VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO};li.setLayoutCount=1;li.pSetLayouts=&descriptor_layout;li.pushConstantRangeCount=1;li.pPushConstantRanges=&push;check(vkCreatePipelineLayout(device,&li,nullptr,&pipeline_layout),"Create pipeline layout");
VkShaderModule vertex{},fragment{},shadow_vertex{};
try{vertex=shader("vertexMain");fragment=shader("fragmentMain");shadow_vertex=shader("shadowMain");for(int mode=0;mode<3;++mode){bool shadow_pass=mode==2,ui=mode==1;
VkPipelineShaderStageCreateInfo stages[2]{};stages[0]={VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO};stages[0].stage=VK_SHADER_STAGE_VERTEX_BIT;stages[0].module=shadow_pass?shadow_vertex:vertex;stages[0].pName="main";stages[1]={VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO};stages[1].stage=VK_SHADER_STAGE_FRAGMENT_BIT;stages[1].module=fragment;stages[1].pName="main";
VkVertexInputBindingDescription binding{0,sizeof(GpuVertex),VK_VERTEX_INPUT_RATE_VERTEX};VkVertexInputAttributeDescription attrs[]={{0,0,VK_FORMAT_R32G32B32A32_SFLOAT,offsetof(GpuVertex,clip)},{1,0,VK_FORMAT_R32G32B32_SFLOAT,offsetof(GpuVertex,world)},{2,0,VK_FORMAT_R32G32B32_SFLOAT,offsetof(GpuVertex,normal)},{3,0,VK_FORMAT_R32G32B32A32_SFLOAT,offsetof(GpuVertex,color)},{4,0,VK_FORMAT_R32G32_SFLOAT,offsetof(GpuVertex,material)},{5,0,VK_FORMAT_R32G32_SFLOAT,offsetof(GpuVertex,uv)}};
VkPipelineVertexInputStateCreateInfo vi{VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO};vi.vertexBindingDescriptionCount=1;vi.pVertexBindingDescriptions=&binding;vi.vertexAttributeDescriptionCount=shadow_pass?1:6;vi.pVertexAttributeDescriptions=shadow_pass?attrs+1:attrs;VkPipelineInputAssemblyStateCreateInfo ia{VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO};ia.topology=VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST;
VkPipelineViewportStateCreateInfo vp{VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO};vp.viewportCount=vp.scissorCount=1;VkPipelineRasterizationStateCreateInfo rs{VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO};rs.polygonMode=VK_POLYGON_MODE_FILL;rs.cullMode=VK_CULL_MODE_NONE;rs.frontFace=VK_FRONT_FACE_COUNTER_CLOCKWISE;rs.lineWidth=1;
VkPipelineMultisampleStateCreateInfo ms{VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO};ms.rasterizationSamples=VK_SAMPLE_COUNT_1_BIT;VkPipelineDepthStencilStateCreateInfo ds{VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO};ds.depthTestEnable=!ui;ds.depthWriteEnable=!ui;ds.depthCompareOp=VK_COMPARE_OP_LESS_OR_EQUAL;
VkPipelineColorBlendAttachmentState blend{};blend.colorWriteMask=15;blend.blendEnable=VK_TRUE;blend.srcColorBlendFactor=VK_BLEND_FACTOR_SRC_ALPHA;blend.dstColorBlendFactor=VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA;blend.colorBlendOp=VK_BLEND_OP_ADD;blend.srcAlphaBlendFactor=VK_BLEND_FACTOR_ONE;blend.dstAlphaBlendFactor=VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA;blend.alphaBlendOp=VK_BLEND_OP_ADD;VkPipelineColorBlendStateCreateInfo cb{VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO};cb.attachmentCount=shadow_pass?0:1;cb.pAttachments=&blend;
VkDynamicState states[]={VK_DYNAMIC_STATE_VIEWPORT,VK_DYNAMIC_STATE_SCISSOR};VkPipelineDynamicStateCreateInfo dynamic{VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO};dynamic.dynamicStateCount=2;dynamic.pDynamicStates=states;VkFormat format=VK_FORMAT_R8G8B8A8_UNORM;VkPipelineRenderingCreateInfo rendering{VK_STRUCTURE_TYPE_PIPELINE_RENDERING_CREATE_INFO};rendering.colorAttachmentCount=shadow_pass?0:1;rendering.pColorAttachmentFormats=&format;rendering.depthAttachmentFormat=VK_FORMAT_D32_SFLOAT;
VkGraphicsPipelineCreateInfo pi{VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO};pi.pNext=&rendering;pi.stageCount=shadow_pass?1:2;pi.pStages=stages;pi.pVertexInputState=&vi;pi.pInputAssemblyState=&ia;pi.pViewportState=&vp;pi.pRasterizationState=&rs;pi.pMultisampleState=&ms;pi.pDepthStencilState=&ds;pi.pColorBlendState=&cb;pi.pDynamicState=&dynamic;pi.layout=pipeline_layout;auto* output=shadow_pass?&shadow_pipeline:ui?&ui_pipeline:&pipeline;check(vkCreateGraphicsPipelines(device,VK_NULL_HANDLE,1,&pi,nullptr,output),"Create graphics pipeline");
}}catch(...){vkDestroyShaderModule(device,vertex,nullptr);vkDestroyShaderModule(device,fragment,nullptr);vkDestroyShaderModule(device,shadow_vertex,nullptr);throw;}
vkDestroyShaderModule(device,vertex,nullptr);vkDestroyShaderModule(device,fragment,nullptr);vkDestroyShaderModule(device,shadow_vertex,nullptr);
}
GpuVertex gpu_vertex(const Vertex& v,const DrawItem& item,const Mat4& vp){
GpuVertex out{};auto world=point(item.model,{v.position[0],v.position[1],v.position[2],1});auto clip=point(vp,world);std::copy(clip.begin(),clip.end(),out.clip);std::copy_n(world.begin(),3,out.world);
// Inverse-transpose 3x3, including nonuniform scale. Singular models have no valid normal.
const auto& m=item.model;Vec3 a{m[0],m[1],m[2]},b{m[4],m[5],m[6]},c{m[8],m[9],m[10]};
auto cross=[](Vec3 x,Vec3 y){return Vec3{x[1]*y[2]-x[2]*y[1],x[2]*y[0]-x[0]*y[2],x[0]*y[1]-x[1]*y[0]};};auto ca=cross(b,c),cb=cross(c,a),cc=cross(a,b);float determinant=a[0]*ca[0]+a[1]*ca[1]+a[2]*ca[2];
for(int i=0;i<3;++i)out.normal[i]=std::abs(determinant)>1e-8f?(ca[i]*v.normal[0]+cb[i]*v.normal[1]+cc[i]*v.normal[2])/determinant:0;
for(int i=0;i<4;++i)out.color[i]=item.color[i]*v.color[i];out.material[0]=item.roughness;out.material[1]=item.metallic;out.uv[0]=v.uv[0];out.uv[1]=v.uv[1];return out;
}
bool outside(const std::vector<GpuVertex>& data,std::size_t start) const {
for(int plane=0;plane<6;++plane){bool all=true;for(std::size_t i=start;i<data.size();++i){auto& p=data[i].clip;float d=plane==0?p[0]+p[3]:plane==1?p[3]-p[0]:plane==2?p[1]+p[3]:plane==3?p[3]-p[1]:plane==4?p[2]:p[3]-p[2];if(d>=0){all=false;break;}}if(all)return true;}return false;
}
void quad(std::vector<GpuVertex>& data,const Quad& q){
const float xy[4][2]={{q.x,q.y},{q.x+q.width,q.y},{q.x+q.width,q.y+q.height},{q.x,q.y+q.height}};
const float uv[4][2]={{q.uv_rect[0],q.uv_rect[1]},{q.uv_rect[2],q.uv_rect[1]},{q.uv_rect[2],q.uv_rect[3]},{q.uv_rect[0],q.uv_rect[3]}};
for(auto i:{0,1,2,0,2,3}){GpuVertex v{};v.clip[0]=xy[i][0]/float(width)*2-1;v.clip[1]=xy[i][1]/float(height)*2-1;v.clip[3]=1;std::copy(q.color.begin(),q.color.end(),v.color);v.uv[0]=uv[i][0];v.uv[1]=uv[i][1];data.push_back(v);}
}
void draw_debug_text(std::vector<GpuVertex>& data,const Text& text){
// Small diagnostic alphabet only. The editor supplies shaped Unicode text as texture quads.
static const std::unordered_map<char,std::array<unsigned char,7>> glyphs={
{'A',{14,17,17,31,17,17,17}},{'B',{30,17,17,30,17,17,30}},{'C',{14,17,16,16,16,17,14}},{'D',{30,17,17,17,17,17,30}},{'E',{31,16,16,30,16,16,31}},{'F',{31,16,16,30,16,16,16}},{'G',{14,17,16,23,17,17,15}},{'H',{17,17,17,31,17,17,17}},{'I',{14,4,4,4,4,4,14}},{'J',{7,2,2,2,18,18,12}},{'K',{17,18,20,24,20,18,17}},{'L',{16,16,16,16,16,16,31}},{'M',{17,27,21,21,17,17,17}},{'N',{17,25,21,19,17,17,17}},{'O',{14,17,17,17,17,17,14}},{'P',{30,17,17,30,16,16,16}},{'Q',{14,17,17,17,21,18,13}},{'R',{30,17,17,30,20,18,17}},{'S',{15,16,16,14,1,1,30}},{'T',{31,4,4,4,4,4,4}},{'U',{17,17,17,17,17,17,14}},{'V',{17,17,17,17,17,10,4}},{'W',{17,17,17,21,21,27,17}},{'X',{17,17,10,4,10,17,17}},{'Y',{17,17,10,4,4,4,4}},{'Z',{31,1,2,4,8,16,31}},
{'0',{14,17,19,21,25,17,14}},{'1',{4,12,4,4,4,4,14}},{'2',{14,17,1,2,4,8,31}},{'3',{30,1,1,14,1,1,30}},{'4',{2,6,10,18,31,2,2}},{'5',{31,16,16,30,1,1,30}},{'6',{14,16,16,30,17,17,14}},{'7',{31,1,2,4,8,8,8}},{'8',{14,17,17,14,17,17,14}},{'9',{14,17,17,15,1,1,14}},
{'.',{0,0,0,0,0,12,12}},{':',{0,12,12,0,12,12,0}},{'-',{0,0,0,31,0,0,0}},{'/',{1,1,2,4,8,16,16}},{'_', {0,0,0,0,0,0,31}},{'(',{2,4,8,8,8,4,2}},{')',{8,4,2,2,2,4,8}},{'+',{0,4,4,31,4,4,0}},{'=',{0,0,31,0,31,0,0}},{'[',{14,8,8,8,8,8,14}},{']',{14,2,2,2,2,2,14}},{'?',{14,17,1,2,4,0,4}},{'!',{4,4,4,4,4,0,4}}
};
float x=text.x,y=text.y,unit=text.size/7;for(unsigned char c:text.value){if(c=='\n'){x=text.x;y+=text.size*1.4f;continue;}if(c>='a'&&c<='z')c-=32;if(c!=' '){auto it=glyphs.find(static_cast<char>(c));auto pattern=it==glyphs.end()?std::array<unsigned char,7>{31,17,17,17,17,17,31}:it->second;for(int row=0;row<7;++row)for(int col=0;col<5;++col)if(pattern[row]&(1<<(4-col)))quad(data,{x+col*unit,y+row*unit,unit,unit,text.color});}x+=6*unit;}
}
void render(const Snapshot& snapshot){
auto start=std::chrono::steady_clock::now();statistics.draw_calls=statistics.culled_meshes=0;
if(surface){int w{},h{};SDL_GetWindowSizeInPixels(window,&w,&h);if(w<=0||h<=0)return;if(dirty_swapchain||!swapchain)make_swapchain();}
// Retire atlas/image resources no longer retained by a caller.
for(auto it=textures.begin();it!=textures.end();){if(it->first!=white.get()&&it->second.source.use_count()==1){destroy(it->second.image);vkFreeDescriptorSets(device,descriptor_pool,1,&it->second.descriptor);it=textures.erase(it);}else ++it;}
const VkDescriptorSet white_descriptor=upload_texture(white);for(const auto& q:snapshot.ui_quads)if(q.texture)upload_texture(q.texture);for(const auto& draw:snapshot.draws)if(draw.texture)upload_texture(draw.texture);for(const auto& sprite:snapshot.sprites)if(sprite.texture)upload_texture(sprite.texture);
std::vector<GpuVertex> data;std::vector<Batch> scene_batches,shadow_batches,ui_batches;
for(const auto& item:snapshot.draws){if(!item.mesh)continue;auto first=data.size();const auto& mesh=*item.mesh;auto emit=[&](std::uint32_t index){if(index>=mesh.vertices.size())throw std::out_of_range("Mesh index outside vertex range");data.push_back(gpu_vertex(mesh.vertices[index],item,snapshot.view_projection));};if(mesh.indices.empty())for(std::uint32_t i=0;i<mesh.vertices.size();++i)emit(i);else for(auto i:mesh.indices)emit(i);auto count=static_cast<std::uint32_t>(data.size()-first);if(count%3)throw std::invalid_argument("Mesh triangle vertex count must be divisible by three");if(!count)continue;Batch batch{static_cast<std::uint32_t>(first),count,item.texture?item.texture.get():white.get()};if(item.cast_shadow)shadow_batches.push_back(batch);if(outside(data,first))++statistics.culled_meshes;else scene_batches.push_back(batch);}
for(const auto& sprite:snapshot.sprites){auto first=static_cast<std::uint32_t>(data.size());float c=std::cos(sprite.rotation),s=std::sin(sprite.rotation);for(auto i:{0,1,2,0,2,3}){const float corners[4][2]={{-.5f,-.5f},{.5f,-.5f},{.5f,.5f},{-.5f,.5f}};float x=corners[i][0]*sprite.size[0],y=corners[i][1]*sprite.size[1];auto clip=point(snapshot.view_projection,{sprite.position[0]+c*x-s*y,sprite.position[1]+s*x+c*y,sprite.position[2],1});GpuVertex vertex{};std::copy(clip.begin(),clip.end(),vertex.clip);std::copy(sprite.color.begin(),sprite.color.end(),vertex.color);vertex.uv[0]=corners[i][0]+.5f;vertex.uv[1]=.5f-corners[i][1];data.push_back(vertex);}scene_batches.push_back({first,6,sprite.texture?sprite.texture.get():white.get()});}
for(const auto& q:snapshot.ui_quads){auto first=static_cast<std::uint32_t>(data.size());quad(data,q);const Texture* texture=q.texture?q.texture.get():white.get();if(!ui_batches.empty()&&ui_batches.back().texture==texture)ui_batches.back().count+=6;else ui_batches.push_back({first,6,texture});}
auto text_first=static_cast<std::uint32_t>(data.size());for(const auto& text:snapshot.ui_text)draw_debug_text(data,text);if(data.size()>text_first)ui_batches.push_back({text_first,static_cast<std::uint32_t>(data.size()-text_first),white.get()});
statistics.vertices=static_cast<std::uint32_t>(data.size());auto byte_count=std::max<std::size_t>(sizeof(GpuVertex),data.size()*sizeof(GpuVertex));if(vertices.size<byte_count){destroy(vertices);vertices=make_buffer(byte_count,VK_BUFFER_USAGE_VERTEX_BUFFER_BIT,VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT|VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);}void* mapped{};check(vkMapMemory(device,vertices.memory,0,vertices.size,0,&mapped),"Map vertices");if(!data.empty())std::memcpy(mapped,data.data(),data.size()*sizeof(GpuVertex));vkUnmapMemory(device,vertices.memory);
Vec3 direction=snapshot.light_direction;float length=std::sqrt(direction[0]*direction[0]+direction[1]*direction[1]+direction[2]*direction[2]);if(length<1e-5f){direction={-.5f,-1,-.3f};length=std::sqrt(1.34f);}for(auto& v:direction)v/=length;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};Push push{multiply(orthographic(-20,20,-20,20,.1f,80),look_at(light_eye,{0,0,0},light_up)),{direction[0],direction[1],direction[2],0},{snapshot.eye[0],snapshot.eye[1],snapshot.eye[2],1}};
std::optional<std::uint32_t> swap_index;
if(surface){std::uint32_t index{};auto result=vkAcquireNextImageKHR(device,swapchain,UINT64_MAX,acquired,VK_NULL_HANDLE,&index);if(result==VK_ERROR_OUT_OF_DATE_KHR){dirty_swapchain=true;return;}if(result==VK_SUBOPTIMAL_KHR)dirty_swapchain=true;else check(result,"Acquire swapchain image");swap_index=index;}
begin();if(timestamp_pool){vkCmdResetQueryPool(command,timestamp_pool,0,2);vkCmdWriteTimestamp2(command,VK_PIPELINE_STAGE_2_TOP_OF_PIPE_BIT,timestamp_pool,0);}VkDeviceSize offset{};vkCmdBindVertexBuffers(command,0,1,&vertices.handle,&offset);vkCmdPushConstants(command,pipeline_layout,VK_SHADER_STAGE_VERTEX_BIT|VK_SHADER_STAGE_FRAGMENT_BIT,0,sizeof(push),&push);
auto set_viewport=[&](std::uint32_t w,std::uint32_t h){VkViewport viewport{0,0,float(w),float(h),0,1};VkRect2D scissor{{0,0},{w,h}};vkCmdSetViewport(command,0,1,&viewport);vkCmdSetScissor(command,0,1,&scissor);};
RenderGraph graph;
graph.add("ShadowMap",{}, {"shadow"},[&]{
transition(command,shadow,VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL,VK_IMAGE_ASPECT_DEPTH_BIT);VkRenderingAttachmentInfo attachment{VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO};attachment.imageView=shadow.view;attachment.imageLayout=VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL;attachment.loadOp=VK_ATTACHMENT_LOAD_OP_CLEAR;attachment.storeOp=VK_ATTACHMENT_STORE_OP_STORE;attachment.clearValue.depthStencil={1,0};VkRenderingInfo rendering{VK_STRUCTURE_TYPE_RENDERING_INFO};rendering.renderArea={{0,0},{shadow_size,shadow_size}};rendering.layerCount=1;rendering.pDepthAttachment=&attachment;vkCmdBeginRendering(command,&rendering);set_viewport(shadow_size,shadow_size);vkCmdBindPipeline(command,VK_PIPELINE_BIND_POINT_GRAPHICS,shadow_pipeline);vkCmdPushConstants(command,pipeline_layout,VK_SHADER_STAGE_VERTEX_BIT|VK_SHADER_STAGE_FRAGMENT_BIT,0,sizeof(push),&push);for(auto batch:shadow_batches){vkCmdDraw(command,batch.count,1,batch.first,0);++statistics.draw_calls;}vkCmdEndRendering(command);transition(command,shadow,VK_IMAGE_LAYOUT_DEPTH_READ_ONLY_OPTIMAL,VK_IMAGE_ASPECT_DEPTH_BIT);
});
graph.add("ForwardAndUI",{"shadow"},{"color","depth"},[&]{
transition(command,color,VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,VK_IMAGE_ASPECT_COLOR_BIT);transition(command,depth,VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL,VK_IMAGE_ASPECT_DEPTH_BIT);VkRenderingAttachmentInfo ca{VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO};ca.imageView=color.view;ca.imageLayout=VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;ca.loadOp=VK_ATTACHMENT_LOAD_OP_CLEAR;ca.storeOp=VK_ATTACHMENT_STORE_OP_STORE;std::copy(snapshot.clear_color.begin(),snapshot.clear_color.end(),ca.clearValue.color.float32);VkRenderingAttachmentInfo da{VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO};da.imageView=depth.view;da.imageLayout=VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL;da.loadOp=VK_ATTACHMENT_LOAD_OP_CLEAR;da.storeOp=VK_ATTACHMENT_STORE_OP_DONT_CARE;da.clearValue.depthStencil={1,0};VkRenderingInfo rendering{VK_STRUCTURE_TYPE_RENDERING_INFO};rendering.renderArea={{0,0},{width,height}};rendering.layerCount=1;rendering.colorAttachmentCount=1;rendering.pColorAttachments=&ca;rendering.pDepthAttachment=&da;vkCmdBeginRendering(command,&rendering);set_viewport(width,height);if(snapshot.scene_rect[2]>0&&snapshot.scene_rect[3]>0){auto r=snapshot.scene_rect;float x=std::clamp(r[0],0.f,float(width)),y=std::clamp(r[1],0.f,float(height));float w=std::min(r[2],float(width)-x),h=std::min(r[3],float(height)-y);VkViewport viewport{x,y,w,h,0,1};VkRect2D scissor{{static_cast<int>(x),static_cast<int>(y)},{static_cast<std::uint32_t>(w),static_cast<std::uint32_t>(h)}};vkCmdSetViewport(command,0,1,&viewport);vkCmdSetScissor(command,0,1,&scissor);}vkCmdBindPipeline(command,VK_PIPELINE_BIND_POINT_GRAPHICS,pipeline);vkCmdPushConstants(command,pipeline_layout,VK_SHADER_STAGE_VERTEX_BIT|VK_SHADER_STAGE_FRAGMENT_BIT,0,sizeof(push),&push);vkCmdBindDescriptorSets(command,VK_PIPELINE_BIND_POINT_GRAPHICS,pipeline_layout,0,1,&white_descriptor,0,nullptr);for(auto batch:scene_batches){auto descriptor=textures.at(batch.texture).descriptor;vkCmdBindDescriptorSets(command,VK_PIPELINE_BIND_POINT_GRAPHICS,pipeline_layout,0,1,&descriptor,0,nullptr);vkCmdDraw(command,batch.count,1,batch.first,0);++statistics.draw_calls;}set_viewport(width,height);vkCmdBindPipeline(command,VK_PIPELINE_BIND_POINT_GRAPHICS,ui_pipeline);vkCmdPushConstants(command,pipeline_layout,VK_SHADER_STAGE_VERTEX_BIT|VK_SHADER_STAGE_FRAGMENT_BIT,0,sizeof(push),&push);for(auto batch:ui_batches){auto descriptor=textures.at(batch.texture).descriptor;vkCmdBindDescriptorSets(command,VK_PIPELINE_BIND_POINT_GRAPHICS,pipeline_layout,0,1,&descriptor,0,nullptr);vkCmdDraw(command,batch.count,1,batch.first,0);++statistics.draw_calls;}vkCmdEndRendering(command);
});
graph.add("Readback",{"color"},{"capture"},[&]{transition(command,color,VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,VK_IMAGE_ASPECT_COLOR_BIT);VkBufferImageCopy copy{};copy.imageSubresource={VK_IMAGE_ASPECT_COLOR_BIT,0,0,1};copy.imageExtent={width,height,1};vkCmdCopyImageToBuffer(command,color.handle,VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,readback.handle,1,&copy);});
if(swap_index)graph.add("Presentation",{"color"},{"swapchain"},[&]{auto index=*swap_index;transition(command,swap_images[index],swap_layouts[index],VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,VK_IMAGE_ASPECT_COLOR_BIT);VkImageBlit blit{};blit.srcSubresource={VK_IMAGE_ASPECT_COLOR_BIT,0,0,1};blit.srcOffsets[1]={static_cast<int>(width),static_cast<int>(height),1};blit.dstSubresource={VK_IMAGE_ASPECT_COLOR_BIT,0,0,1};blit.dstOffsets[1]={static_cast<int>(swap_extent.width),static_cast<int>(swap_extent.height),1};vkCmdBlitImage(command,color.handle,VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,swap_images[index],VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,1,&blit,VK_FILTER_NEAREST);transition(command,swap_images[index],swap_layouts[index],VK_IMAGE_LAYOUT_PRESENT_SRC_KHR,VK_IMAGE_ASPECT_COLOR_BIT);});
graph.execute();if(timestamp_pool)vkCmdWriteTimestamp2(command,VK_PIPELINE_STAGE_2_BOTTOM_OF_PIPE_BIT,timestamp_pool,1);submit(swap_index.has_value());if(timestamp_pool){std::uint64_t stamps[2]{};check(vkGetQueryPoolResults(device,timestamp_pool,0,2,sizeof(stamps),stamps,sizeof(std::uint64_t),VK_QUERY_RESULT_64_BIT|VK_QUERY_RESULT_WAIT_BIT),"Read GPU timestamps");auto delta=stamps[1]-stamps[0];if(timestamp_bits<64)delta&=(std::uint64_t(1)<<timestamp_bits)-1;statistics.gpu_ms=double(delta)*timestamp_period/1000000.0;}
if(swap_index){VkPresentInfoKHR present{VK_STRUCTURE_TYPE_PRESENT_INFO_KHR};present.waitSemaphoreCount=1;present.pWaitSemaphores=&present_ready;present.swapchainCount=1;present.pSwapchains=&swapchain;present.pImageIndices=&*swap_index;auto result=vkQueuePresentKHR(queue,&present);if(result==VK_ERROR_OUT_OF_DATE_KHR||result==VK_SUBOPTIMAL_KHR)dirty_swapchain=true;else check(result,"Present frame");check(vkQueueWaitIdle(queue),"Wait presentation");}
last_pixels.resize(std::size_t(width)*height*4);check(vkMapMemory(device,readback.memory,0,readback.size,0,&mapped),"Map captured frame");std::memcpy(last_pixels.data(),mapped,last_pixels.size());vkUnmapMemory(device,readback.memory);++statistics.frame;statistics.validation_errors=validation_errors.load();statistics.cpu_ms=std::chrono::duration<double,std::milli>(std::chrono::steady_clock::now()-start).count();
}
};
Renderer::Renderer(const RendererConfig& config):impl_(std::make_unique<Impl>()){impl_->initialize(config);}
Renderer::~Renderer()=default;
Renderer::Renderer(Renderer&&) noexcept=default;
Renderer& Renderer::operator=(Renderer&&) noexcept=default;
void Renderer::render(const Snapshot& snapshot){impl_->render(snapshot);}
bool Renderer::reload_shaders(std::string& error){
auto& r=*impl_;check(vkDeviceWaitIdle(r.device),"Wait shader reload");
auto previous_layout=r.pipeline_layout;auto previous=r.pipeline;auto previous_ui=r.ui_pipeline;auto previous_shadow=r.shadow_pipeline;
r.pipeline_layout={};r.pipeline={};r.ui_pipeline={};r.shadow_pipeline={};
try{r.make_pipelines();}catch(const std::exception& exception){
if(r.pipeline)vkDestroyPipeline(r.device,r.pipeline,nullptr);if(r.ui_pipeline)vkDestroyPipeline(r.device,r.ui_pipeline,nullptr);if(r.shadow_pipeline)vkDestroyPipeline(r.device,r.shadow_pipeline,nullptr);if(r.pipeline_layout)vkDestroyPipelineLayout(r.device,r.pipeline_layout,nullptr);
r.pipeline_layout=previous_layout;r.pipeline=previous;r.ui_pipeline=previous_ui;r.shadow_pipeline=previous_shadow;error=exception.what();return false;
}
vkDestroyPipeline(r.device,previous,nullptr);vkDestroyPipeline(r.device,previous_ui,nullptr);vkDestroyPipeline(r.device,previous_shadow,nullptr);vkDestroyPipelineLayout(r.device,previous_layout,nullptr);error.clear();return true;
}
void Renderer::resize(std::uint32_t w,std::uint32_t h){if(!w||!h)return;if(impl_->window){SDL_SetWindowSize(impl_->window,static_cast<int>(w),static_cast<int>(h));impl_->dirty_swapchain=true;}else if(w!=impl_->width||h!=impl_->height){impl_->width=w;impl_->height=h;impl_->make_targets();}}
std::uint32_t Renderer::width()const{return impl_->width;}
std::uint32_t Renderer::height()const{return impl_->height;}
bool Renderer::should_close()const{return impl_->close;}
const FrameStats& Renderer::stats()const{return impl_->statistics;}
std::vector<std::uint8_t> Renderer::pixels()const{return impl_->last_pixels;}
void Renderer::capture(const std::filesystem::path& path){if(impl_->last_pixels.empty())throw std::runtime_error("Cannot capture before a completed frame");std::ofstream out(path,std::ios::binary);if(!out)throw std::runtime_error("Cannot write screenshot: "+path.string());out<<"P6\n"<<width()<<' '<<height()<<"\n255\n";for(std::size_t i=0;i<impl_->last_pixels.size();i+=4)out.write(reinterpret_cast<const char*>(impl_->last_pixels.data()+i),3);if(!out)throw std::runtime_error("Screenshot write failed");}
void Renderer::set_title(const std::string& title){if(impl_->window)SDL_SetWindowTitle(impl_->window,title.c_str());}
void Renderer::set_text_input(bool enabled){if(!impl_->window)return;if(enabled)SDL_StartTextInput(impl_->window);else SDL_StopTextInput(impl_->window);}
void Renderer::set_text_input_area(float x,float y,float width,float height){
if(!impl_->window)return;int w{},h{},pw{},ph{};SDL_GetWindowSize(impl_->window,&w,&h);SDL_GetWindowSizeInPixels(impl_->window,&pw,&ph);float sx=pw>0?float(w)/float(pw):1,sy=ph>0?float(h)/float(ph):1;SDL_Rect rectangle{int(x*sx),int(y*sy),std::max(1,int(width*sx)),std::max(1,int(height*sy))};if(!SDL_SetTextInputArea(impl_->window,&rectangle,0))throw std::runtime_error(SDL_GetError());
}
void Renderer::set_clipboard(const std::string& text){if(!SDL_SetClipboardText(text.c_str()))throw std::runtime_error(SDL_GetError());}
std::string Renderer::clipboard()const{char* text=SDL_GetClipboardText();if(!text)return {};std::string result=text;SDL_free(text);return result;}
std::vector<Event> Renderer::poll_events(){
std::vector<Event> result;SDL_Event event{};while(SDL_PollEvent(&event)){Event item;bool emit=true;auto modifiers=SDL_GetModState();item.control=(modifiers&SDL_KMOD_CTRL)!=0;item.shift=(modifiers&SDL_KMOD_SHIFT)!=0;item.alt=(modifiers&SDL_KMOD_ALT)!=0;
switch(event.type){
case SDL_EVENT_QUIT:case SDL_EVENT_WINDOW_CLOSE_REQUESTED:item.type=Event::Type::Quit;impl_->close=true;break;
case SDL_EVENT_WINDOW_RESIZED:case SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED:item.type=Event::Type::Resize;item.x=float(event.window.data1);item.y=float(event.window.data2);impl_->dirty_swapchain=true;break;
case SDL_EVENT_WINDOW_FOCUS_GAINED:item.type=Event::Type::FocusGained;break;
case SDL_EVENT_WINDOW_FOCUS_LOST:item.type=Event::Type::FocusLost;break;
case SDL_EVENT_MOUSE_MOTION:item.type=Event::Type::MouseMove;item.x=event.motion.x;item.y=event.motion.y;break;
case SDL_EVENT_MOUSE_BUTTON_DOWN:case SDL_EVENT_MOUSE_BUTTON_UP:item.type=event.type==SDL_EVENT_MOUSE_BUTTON_DOWN?Event::Type::MouseDown:Event::Type::MouseUp;item.x=event.button.x;item.y=event.button.y;item.button=event.button.button;break;
case SDL_EVENT_MOUSE_WHEEL:item.type=Event::Type::Wheel;item.x=event.wheel.x;item.y=event.wheel.y;break;
case SDL_EVENT_KEY_DOWN:case SDL_EVENT_KEY_UP:item.type=event.type==SDL_EVENT_KEY_DOWN?Event::Type::KeyDown:Event::Type::KeyUp;item.key=SDL_GetKeyName(event.key.key);item.repeat=event.key.repeat;break;
case SDL_EVENT_TEXT_INPUT:item.type=Event::Type::TextInput;item.text=event.text.text;break;
case SDL_EVENT_TEXT_EDITING:item.type=Event::Type::TextEditing;item.text=event.edit.text;item.edit_start=event.edit.start;item.edit_length=event.edit.length;break;
default:emit=false;
}
// Rendering/UI coordinates use drawable pixels; SDL pointer events use logical window units.
if(impl_->window&&(item.type==Event::Type::MouseMove||item.type==Event::Type::MouseDown||item.type==Event::Type::MouseUp)){int w{},h{},pw{},ph{};SDL_GetWindowSize(impl_->window,&w,&h);SDL_GetWindowSizeInPixels(impl_->window,&pw,&ph);if(w>0&&h>0){item.x*=float(pw)/float(w);item.y*=float(ph)/float(h);}}
if(emit)result.push_back(std::move(item));
}return result;
}
}
+117
View File
@@ -0,0 +1,117 @@
#include "Physics.hpp"
#include <box2d/box2d.h>
#include <box3d/box3d.h>
#include <algorithm>
#include <cmath>
#include <stdexcept>
#include <unordered_map>
namespace faset::runtime::detail {
namespace {
b3Quat quaternion(Vec3 e) {
auto x = b3MakeQuatFromAxisAngle({1, 0, 0}, e[0]);
auto y = b3MakeQuatFromAxisAngle({0, 1, 0}, e[1]);
auto z = b3MakeQuatFromAxisAngle({0, 0, 1}, e[2]);
return b3MulQuat(z, b3MulQuat(y, x));
}
Vec3 euler(b3Quat q) {
const float x=q.v.x, y=q.v.y, z=q.v.z, w=q.s;
return {std::atan2(2*(w*x+y*z), 1-2*(x*x+y*y)),
std::asin(std::clamp(2*(w*y-z*x), -1.0f, 1.0f)),
std::atan2(2*(w*z+x*y), 1-2*(y*y+z*z))};
}
}
struct Physics::Impl {
struct Body { b2BodyId two{}; b3BodyId three{}; std::uint64_t shape{}; bool dynamic{}; };
int dimension;
int substeps;
b2WorldId world2{};
b3WorldId world3{};
std::unordered_map<std::uint32_t, Body> bodies;
std::unordered_map<std::uint64_t, std::uint32_t> shapes;
Impl(int dim, Vec3 gravity, int count):dimension(dim),substeps(count) {
if(dim==2) { auto def=b2DefaultWorldDef(); def.gravity={gravity[0],gravity[1]}; world2=b2CreateWorld(&def); }
else { auto def=b3DefaultWorldDef(); def.gravity={gravity[0],gravity[1],gravity[2]}; world3=b3CreateWorld(&def); }
}
~Impl() { if(dimension==2) b2DestroyWorld(world2); else b3DestroyWorld(world3); }
};
Physics::Physics(int dimension, Vec3 gravity, int substeps):impl_(std::make_unique<Impl>(dimension,gravity,substeps)){}
Physics::~Physics()=default;
void Physics::add(std::uint32_t id, const Transform& t, const BodySettings& settings) {
if(contains(id)) throw std::logic_error("physics body already exists");
Impl::Body body{}; body.dynamic=settings.type=="dynamic";
if(impl_->dimension==2) {
auto def=b2DefaultBodyDef();
def.type=settings.type=="static"?b2_staticBody:settings.type=="kinematic"?b2_kinematicBody:b2_dynamicBody;
def.position={t.position[0],t.position[1]}; def.rotation=b2MakeRot(t.rotation[2]);
def.linearVelocity={settings.linearVelocity[0],settings.linearVelocity[1]}; def.gravityScale=settings.gravityScale;
body.two=b2CreateBody(impl_->world2,&def);
auto shape=b2DefaultShapeDef(); shape.density=settings.density; shape.material.friction=settings.friction;
shape.material.restitution=settings.restitution; shape.enableContactEvents=true;
shape.filter.categoryBits=settings.categoryBits; shape.filter.maskBits=settings.maskBits;
const auto box=b2MakeBox(settings.halfExtents[0]*std::abs(t.scale[0]),settings.halfExtents[1]*std::abs(t.scale[1]));
body.shape=b2StoreShapeId(b2CreatePolygonShape(body.two,&shape,&box));
} else {
auto def=b3DefaultBodyDef();
def.type=settings.type=="static"?b3_staticBody:settings.type=="kinematic"?b3_kinematicBody:b3_dynamicBody;
def.position={t.position[0],t.position[1],t.position[2]}; def.rotation=quaternion(t.rotation);
def.linearVelocity={settings.linearVelocity[0],settings.linearVelocity[1],settings.linearVelocity[2]}; def.gravityScale=settings.gravityScale;
body.three=b3CreateBody(impl_->world3,&def);
auto shape=b3DefaultShapeDef(); shape.density=settings.density; shape.baseMaterial.friction=settings.friction;
shape.baseMaterial.restitution=settings.restitution; shape.enableContactEvents=true;
shape.filter.categoryBits=settings.categoryBits; shape.filter.maskBits=settings.maskBits;
auto box=b3MakeBoxHull(settings.halfExtents[0]*std::abs(t.scale[0]),settings.halfExtents[1]*std::abs(t.scale[1]),settings.halfExtents[2]*std::abs(t.scale[2]));
body.shape=b3StoreShapeId(b3CreateHullShape(body.three,&shape,&box.base));
}
impl_->shapes.emplace(body.shape,id); impl_->bodies.emplace(id,body);
}
void Physics::remove(std::uint32_t id) {
const auto it=impl_->bodies.find(id); if(it==impl_->bodies.end()) return;
impl_->shapes.erase(it->second.shape);
if(impl_->dimension==2) b2DestroyBody(it->second.two); else b3DestroyBody(it->second.three);
impl_->bodies.erase(it);
}
bool Physics::contains(std::uint32_t id) const { return impl_->bodies.contains(id); }
bool Physics::dynamic(std::uint32_t id) const { return impl_->bodies.at(id).dynamic; }
Transform Physics::transform(std::uint32_t id, Transform t) const {
const auto& body=impl_->bodies.at(id);
if(impl_->dimension==2) { auto p=b2Body_GetPosition(body.two); t.position[0]=p.x;t.position[1]=p.y;t.rotation[2]=b2Rot_GetAngle(b2Body_GetRotation(body.two)); }
else { auto p=b3Body_GetPosition(body.three);t.position={float(p.x),float(p.y),float(p.z)};t.rotation=euler(b3Body_GetRotation(body.three)); }
return t;
}
Vec3 Physics::velocity(std::uint32_t id) const {
const auto& body=impl_->bodies.at(id);
if(impl_->dimension==2) { auto v=b2Body_GetLinearVelocity(body.two);return {v.x,v.y,0}; }
auto v=b3Body_GetLinearVelocity(body.three);return {v.x,v.y,v.z};
}
void Physics::teleport(std::uint32_t id, const Transform& t) {
const auto& body=impl_->bodies.at(id);
if(impl_->dimension==2) b2Body_SetTransform(body.two,{t.position[0],t.position[1]},b2MakeRot(t.rotation[2]));
else b3Body_SetTransform(body.three,{t.position[0],t.position[1],t.position[2]},quaternion(t.rotation));
}
void Physics::setVelocity(std::uint32_t id, Vec3 v) {
const auto& body=impl_->bodies.at(id);
if(impl_->dimension==2) b2Body_SetLinearVelocity(body.two,{v[0],v[1]}); else b3Body_SetLinearVelocity(body.three,{v[0],v[1],v[2]});
}
void Physics::impulse(std::uint32_t id, Vec3 v) {
const auto& body=impl_->bodies.at(id);
if(impl_->dimension==2) b2Body_ApplyLinearImpulseToCenter(body.two,{v[0],v[1]},true); else b3Body_ApplyLinearImpulseToCenter(body.three,{v[0],v[1],v[2]},true);
}
std::vector<Contact> Physics::step(float delta) {
std::vector<Contact> contacts;
auto append=[&](std::uint64_t a,std::uint64_t b,bool began) {
auto first=impl_->shapes.find(a),second=impl_->shapes.find(b);
if(first!=impl_->shapes.end() && second!=impl_->shapes.end()) contacts.push_back({first->second,second->second,began});
};
if(impl_->dimension==2) {
b2World_Step(impl_->world2,delta,impl_->substeps);auto events=b2World_GetContactEvents(impl_->world2);
for(int i=0;i<events.beginCount;++i) append(b2StoreShapeId(events.beginEvents[i].shapeIdA),b2StoreShapeId(events.beginEvents[i].shapeIdB),true);
for(int i=0;i<events.endCount;++i) append(b2StoreShapeId(events.endEvents[i].shapeIdA),b2StoreShapeId(events.endEvents[i].shapeIdB),false);
} else {
b3World_Step(impl_->world3,delta,impl_->substeps);auto events=b3World_GetContactEvents(impl_->world3);
for(int i=0;i<events.beginCount;++i) append(b3StoreShapeId(events.beginEvents[i].shapeIdA),b3StoreShapeId(events.beginEvents[i].shapeIdB),true);
for(int i=0;i<events.endCount;++i) append(b3StoreShapeId(events.endEvents[i].shapeIdA),b3StoreShapeId(events.endEvents[i].shapeIdB),false);
}
return contacts;
}
}
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include <faset/runtime/Runtime.hpp>
#include <memory>
namespace faset::runtime::detail {
struct BodySettings {
std::string type{"dynamic"};
Vec3 halfExtents{0.5f, 0.5f, 0.5f};
Vec3 linearVelocity{};
float density{1};
float friction{0.3f};
float restitution{};
float gravityScale{1};
std::uint64_t categoryBits{1};
std::uint64_t maskBits{~std::uint64_t{0}};
};
struct Contact { std::uint32_t first; std::uint32_t second; bool began; };
class Physics {
public:
Physics(int dimension, Vec3 gravity, int substeps);
~Physics();
void add(std::uint32_t id, const Transform&, const BodySettings&);
void remove(std::uint32_t id);
bool contains(std::uint32_t id) const;
bool dynamic(std::uint32_t id) const;
Transform transform(std::uint32_t id, Transform previous) const;
Vec3 velocity(std::uint32_t id) const;
void teleport(std::uint32_t id, const Transform&);
void setVelocity(std::uint32_t id, Vec3);
void impulse(std::uint32_t id, Vec3);
std::vector<Contact> step(float delta);
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
}
+293
View File
@@ -0,0 +1,293 @@
#include <faset/runtime/Runtime.hpp>
#include "Physics.hpp"
#include <entt/entt.hpp>
#include <algorithm>
#include <atomic>
#include <cmath>
#include <deque>
#include <numbers>
#include <set>
#include <stdexcept>
#include <unordered_map>
namespace faset::runtime {
namespace {
using Json=nlohmann::json;
std::atomic<std::uint64_t> nextSession{1};
constexpr const char* body2="faset.rigid_body_2d";
constexpr const char* body3="faset.rigid_body_3d";
void require(bool condition,const std::string& message) { if(!condition) throw std::invalid_argument(message); }
template<std::size_t N> std::array<float,N> vectorValue(const Json& object,const char* key,std::array<float,N> fallback) {
if(!object.contains(key)) return fallback;
const auto& value=object.at(key); require(value.is_array()&&value.size()==N,std::string(key)+": wrong vector size");
for(std::size_t i=0;i<N;++i) { require(value[i].is_number(),std::string(key)+": expected number"); fallback[i]=value[i].get<float>(); require(std::isfinite(fallback[i]),std::string(key)+": nonfinite value"); }
return fallback;
}
float number(const Json& fields,const char* key,float fallback) {
if(!fields.contains(key)) return fallback;
require(fields.at(key).is_number(),std::string(key)+": expected number");
float v=fields.at(key).get<float>();require(std::isfinite(v),std::string(key)+": nonfinite value");return v;
}
Transform readTransform(const Json& fields) {
return {vectorValue<3>(fields,"position",{0,0,0}),vectorValue<3>(fields,"rotation",{0,0,0}),vectorValue<3>(fields,"scale",{1,1,1})};
}
void validateTransform(const Transform& t) {
for(const auto& values:{t.position,t.rotation,t.scale}) for(float value:values) require(std::isfinite(value),"nonfinite transform");
}
Json transformJson(const Transform& t) { return {{"position",t.position},{"rotation",t.rotation},{"scale",t.scale}}; }
detail::BodySettings settings(const Json& fields,int dimension) {
detail::BodySettings b;
b.type=fields.value("body_type",std::string("dynamic"));require(b.type=="dynamic"||b.type=="static"||b.type=="kinematic","invalid body_type");
if(dimension==2) {
auto half=vectorValue<2>(fields,"half_extents",{0.5f,0.5f});b.halfExtents={half[0],half[1],0.5f};
auto vel=vectorValue<2>(fields,"linear_velocity",{0,0});b.linearVelocity={vel[0],vel[1],0};
} else { b.halfExtents=vectorValue<3>(fields,"half_extents",{0.5f,0.5f,0.5f});b.linearVelocity=vectorValue<3>(fields,"linear_velocity",{0,0,0}); }
for(float extent:b.halfExtents) require(extent>0&&extent<100000,"half_extents must be positive and finite");
b.density=number(fields,"density",1); b.friction=number(fields,"friction",0.3f);
b.restitution=number(fields,"restitution",0);b.gravityScale=number(fields,"gravity_scale",1);
require(b.density>0&&b.friction>=0&&b.restitution>=0&&b.restitution<=1,"invalid physics material");
auto bits=[&](const char* name,std::uint64_t fallback) { if(!fields.contains(name))return fallback; const auto& value=fields.at(name);require(value.is_number_unsigned()||(value.is_number_integer()&&value.get<std::int64_t>()>=0),std::string(name)+": expected nonnegative bits");return value.get<std::uint64_t>(); };
b.categoryBits=bits("category_bits",1);b.maskBits=bits("mask_bits",~std::uint64_t{0});return b;
}
void validateEntity(const Json& entity,int dimension) {
require(entity.is_object(),"entity must be an object");
require(entity.contains("id")&&entity["id"].is_string()&&!entity["id"].get<std::string>().empty(),"entity requires id");
require(!entity.contains("name")||entity["name"].is_string(),"entity name must be a string");
require(entity.contains("components")&&entity["components"].is_array(),"entity requires components array");
if(entity.contains("parent"))require(entity["parent"].is_null()||entity["parent"].is_string(),"parent must be an id or null");
std::set<std::string> types, ids;Transform transform{};bool physical=false;
for(const auto& component:entity["components"]) {
require(component.is_object()&&component.contains("id")&&component["id"].is_string()&&!component["id"].get<std::string>().empty(),"component requires id");
require(component.contains("type")&&component["type"].is_string()&&!component["type"].get<std::string>().empty(),"component requires type");
require(component.value("version",1)==1,"unsupported component version");
require(component.contains("fields")&&component["fields"].is_object(),"component requires fields");
require(ids.insert(component["id"].get<std::string>()).second,"duplicate component id");
const auto type=component["type"].get<std::string>();require(types.insert(type).second,"duplicate component type");
const auto& f=component["fields"];
if(type=="faset.transform")transform=readTransform(f);
if(type==body2||type==body3) { require(type==(dimension==2?body2:body3),"physics dimension does not match scene");settings(f,dimension);physical=true; }
if(type=="faset.sprite") {vectorValue<4>(f,"color",{1,1,1,1});auto size=vectorValue<2>(f,"size",{1,1});require(size[0]>0&&size[1]>0,"sprite size must be positive");require(!f.contains("texture")||f["texture"].is_string(),"sprite texture must be a string");require(!f.contains("layer")||f["layer"].is_number_integer(),"sprite layer must be an integer");}
if(type=="faset.mesh") {vectorValue<4>(f,"color",{1,1,1,1});require(!f.contains("asset")||f["asset"].is_string(),"mesh asset must be a string");require(!f.contains("primitive")||f["primitive"].is_string(),"mesh primitive must be a string");}
}
if(physical) {
require(!entity.contains("parent")||entity["parent"].is_null(),"physics bodies must be root entities in the initial runtime");
for(int i=0;i<dimension;++i)require(std::abs(transform.scale[i])>0.00001f,"physics scale must be nonzero");
if(dimension==2) require(transform.rotation[0]==0&&transform.rotation[1]==0,"2D physics rotates only around Z");
}
}
Transform interpolate(const Transform& a,const Transform& b,float alpha) {
Transform out;
for(int i=0;i<3;++i) {
out.position[i]=std::lerp(a.position[i],b.position[i],alpha);out.scale[i]=std::lerp(a.scale[i],b.scale[i],alpha);
}
auto quaternion=[](Vec3 r) {
const float cx=std::cos(r[0]*0.5f),sx=std::sin(r[0]*0.5f),cy=std::cos(r[1]*0.5f),sy=std::sin(r[1]*0.5f),cz=std::cos(r[2]*0.5f),sz=std::sin(r[2]*0.5f);
return Vec4{sx*cy*cz-cx*sy*sz,cx*sy*cz+sx*cy*sz,cx*cy*sz-sx*sy*cz,cx*cy*cz+sx*sy*sz};
};
auto qa=quaternion(a.rotation),qb=quaternion(b.rotation);float dot=0;
for(int i=0;i<4;++i)dot+=qa[i]*qb[i];
if(dot<0){for(auto& q:qb)q=-q;dot=-dot;}
float wa=1-alpha,wb=alpha;
if(dot<0.9995f){const float angle=std::acos(std::clamp(dot,-1.0f,1.0f)),denom=std::sin(angle);wa=std::sin((1-alpha)*angle)/denom;wb=std::sin(alpha*angle)/denom;}
Vec4 q{};float length=0;for(int i=0;i<4;++i){q[i]=wa*qa[i]+wb*qb[i];length+=q[i]*q[i];}for(auto& v:q)v/=std::sqrt(length);
const auto [x,y,z,w]=q;
out.rotation={std::atan2(2*(w*x+y*z),1-2*(x*x+y*y)),std::asin(std::clamp(2*(w*y-z*x),-1.0f,1.0f)),std::atan2(2*(w*z+x*y),1-2*(y*y+z*z))};
return out;
}
}
struct Runtime::Impl {
struct Data { Json document; std::uint64_t generation; };
struct Pose { Transform previous,current,presented; bool changedInUpdate{}; };
enum class Phase { Idle, Initialize, Fixed, Update, Late, Destroy };
enum class Kind { Spawn, Destroy, Add, Remove };
struct Command { Kind kind; EntityHandle handle; Json payload; std::string type; };
Runtime* owner;
RuntimeConfig config;
entt::registry registry;
std::unordered_map<std::string,entt::entity> ids;
std::unordered_map<std::string,Behavior> behaviors;
std::vector<entt::entity> order;
std::deque<Command> pending;
std::unique_ptr<detail::Physics> physics;
std::vector<CollisionEvent> contacts;
std::vector<std::string> diagnostics;
std::uint64_t session{nextSession.fetch_add(1)};
std::uint64_t generation{},tick{};
int dimension{3};
double accumulator{},alpha{};
bool paused{},busy{};
InputState currentInput{},queuedInput{};
Phase phase{Phase::Idle};
Impl(Runtime* runtime,RuntimeConfig cfg):owner(runtime),config(cfg){}
EntityHandle handle(entt::entity e) const {return {session,entt::to_integral(e),registry.get<Data>(e).generation};}
bool valid(EntityHandle h)const noexcept { auto e=static_cast<entt::entity>(h.slot);return h.session==session&&registry.valid(e)&&registry.all_of<Data>(e)&&registry.get<Data>(e).generation==h.generation; }
entt::entity entity(EntityHandle h)const {if(!valid(h))throw std::invalid_argument("stale or foreign runtime handle");return static_cast<entt::entity>(h.slot);}
const Json* component(entt::entity e,const std::string& type)const {for(const auto& c:registry.get<Data>(e).document["components"])if(c["type"]==type)return &c;return nullptr;}
void callback(const Behavior::Callback& fn,entt::entity e,double dt) {
if(!fn)return;
try {fn(*owner,handle(e),dt);}catch(const std::exception& ex){diagnostics.push_back("gameplay "+registry.get<Data>(e).document["id"].get<std::string>()+": "+ex.what());}catch(...){diagnostics.push_back("unknown gameplay exception");}
}
void lifecycle(entt::entity e,Behavior::Callback Behavior::* member,double dt) {
const auto components=registry.get<Data>(e).document["components"];
for(const auto& c:components) {auto it=behaviors.find(c["type"].get<std::string>());if(it!=behaviors.end())callback(it->second.*member,e,dt);}
}
void all(Behavior::Callback Behavior::* member,double dt) {for(auto e:order)if(registry.valid(e))lifecycle(e,member,dt);}
void syncVisual(entt::entity e) {
if(auto c=component(e,"faset.sprite")){const auto& f=(*c)["fields"];registry.emplace_or_replace<Sprite>(e,vectorValue<4>(f,"color",{1,1,1,1}),vectorValue<2>(f,"size",{1,1}),f.value("texture",std::string{}),f.value("layer",0));}else registry.remove<Sprite>(e);
if(auto c=component(e,"faset.mesh")){const auto& f=(*c)["fields"];registry.emplace_or_replace<Mesh>(e,f.value("asset",std::string{}),vectorValue<4>(f,"color",{1,1,1,1}),f.value("primitive",std::string("cube")));}else registry.remove<Mesh>(e);
}
void addPhysics(entt::entity e) {
require(bool(physics),"load a scene before creating physics");
auto c=component(e,dimension==2?body2:body3);if(c)physics->add(entt::to_integral(e),registry.get<Pose>(e).current,settings((*c)["fields"],dimension));
}
entt::entity create(Json document) {
const auto id=document["id"].get<std::string>();require(!ids.contains(id),"duplicate entity id: "+id);
auto e=registry.create();Transform t{};
for(const auto& c:document["components"])if(c["type"]=="faset.transform")t=readTransform(c["fields"]);
registry.emplace<Data>(e,std::move(document),++generation);registry.emplace<Pose>(e,t,t,t,false);ids.emplace(id,e);order.push_back(e);addPhysics(e);syncVisual(e);return e;
}
void erase(entt::entity e) {
// Authoring hierarchy destruction has the same subtree semantics in runtime.
auto id=registry.get<Data>(e).document["id"].get<std::string>();
std::vector<entt::entity> children;
for(auto child:order)if(registry.valid(child)&&registry.get<Data>(child).document.value("parent",Json{})==id)children.push_back(child);
for(auto child:children)erase(child);
phase=Phase::Destroy;lifecycle(e,&Behavior::onDestroy,0);
physics->remove(entt::to_integral(e));ids.erase(id);registry.destroy(e);
std::erase(order,e);
}
void commands() {
auto commands=std::move(pending);pending.clear();
for(auto& command:commands)try {
if(command.kind==Kind::Spawn) {
validateEntity(command.payload,dimension);
auto parent=command.payload.value("parent",Json{});require(parent.is_null()||ids.contains(parent.get<std::string>()),"spawn parent is absent");
auto e=create(std::move(command.payload));phase=Phase::Initialize;lifecycle(e,&Behavior::onStart,0);continue;
}
if(!valid(command.handle)){diagnostics.push_back("ignored structural command for stale handle");continue;}
auto e=entity(command.handle);
if(command.kind==Kind::Destroy){erase(e);continue;}
auto candidate=registry.get<Data>(e).document;
auto& components=candidate["components"];
if(command.kind==Kind::Add) {components.push_back(command.payload);validateEntity(candidate,dimension);}
else { auto it=std::find_if(components.begin(),components.end(),[&](const Json& c){return c["type"]==command.type;});if(it==components.end())continue;
if(command.type=="faset.transform"&&physics->contains(command.handle.slot))throw std::invalid_argument("remove physics before removing transform");
auto behavior=behaviors.find(command.type);if(behavior!=behaviors.end()){phase=Phase::Destroy;callback(behavior->second.onDestroy,e,0);}components.erase(it);
}
const std::string changed=command.kind==Kind::Add?command.payload["type"].get<std::string>():command.type;
if((changed==body2||changed==body3)&&command.kind==Kind::Add) {
const auto& pose=registry.get<Pose>(e).current;
for(int i=0;i<dimension;++i)require(std::abs(pose.scale[i])>0.00001f,"runtime physics scale must be nonzero");
if(dimension==2)require(pose.rotation[0]==0&&pose.rotation[1]==0,"2D physics rotates only around Z");
}
registry.get<Data>(e).document=std::move(candidate);syncVisual(e);
if(changed==body2||changed==body3) {if(command.kind==Kind::Add)addPhysics(e);else physics->remove(command.handle.slot);}
if(changed=="faset.transform") {auto& d=registry.get<Pose>(e);d.current=command.kind==Kind::Add?readTransform(command.payload["fields"]):Transform{};d.previous=d.presented=d.current;}
if(command.kind==Kind::Add){auto it=behaviors.find(changed);if(it!=behaviors.end()){phase=Phase::Initialize;callback(it->second.onStart,e,0);}}
}catch(const std::exception& ex){diagnostics.push_back(std::string("structural command rejected: ")+ex.what());}
}
void fixed() {
commands();phase=Phase::Fixed;
for(auto [e,d]:registry.view<Pose>().each()){(void)e;d.previous=d.current;}
currentInput=queuedInput;queuedInput.jumpPressed=false;queuedInput.interactPressed=false;
all(&Behavior::fixedUpdate,config.fixedDelta);
contacts.clear();
if(physics) {
auto events=physics->step(static_cast<float>(config.fixedDelta));
for(auto e:order)if(physics->contains(entt::to_integral(e))) {auto& d=registry.get<Pose>(e);d.current=physics->transform(entt::to_integral(e),d.current);}
for(const auto& event:events) {
auto a=static_cast<entt::entity>(event.first),b=static_cast<entt::entity>(event.second);
if(!registry.valid(a)||!registry.valid(b))continue;
contacts.push_back({handle(a),handle(b),event.began});
}
for(const auto& event:contacts)for(auto h:{event.first,event.second}) {
auto e=entity(h);for(const auto& c:registry.get<Data>(e).document["components"]) {
auto it=behaviors.find(c["type"].get<std::string>());if(it!=behaviors.end()&&it->second.onCollision)
try{it->second.onCollision(*owner,h,event);}catch(const std::exception& ex){diagnostics.push_back(std::string("collision callback: ")+ex.what());}catch(...){diagnostics.push_back("unknown collision callback exception");}
}
}
}
++tick;phase=Phase::Idle;
}
FrameStats frame(double elapsed,InputState input,bool step) {
require(std::isfinite(elapsed)&&elapsed>=0,"elapsed time must be finite and nonnegative");require(!busy,"recursive runtime advance");
require(std::isfinite(input.horizontal)&&std::isfinite(input.vertical),"input axes must be finite");
struct Guard {bool& busy;~Guard(){busy=false;}}guard{busy};busy=true;
FrameStats stats{};
if(paused&&!step){accumulator=0;currentInput={};queuedInput={};return {0,0,alpha,tick};}
queuedInput.horizontal=input.horizontal;queuedInput.vertical=input.vertical;
queuedInput.jumpPressed=queuedInput.jumpPressed||input.jumpPressed;queuedInput.interactPressed=queuedInput.interactPressed||input.interactPressed;
for(auto [e,d]:registry.view<Pose>().each()){(void)e;d.changedInUpdate=false;}
accumulator+=step?config.fixedDelta:elapsed;
while(accumulator+1e-12>=config.fixedDelta&&stats.fixedTicks<(step?1u:config.maxCatchUpTicks)) {fixed();accumulator=std::max(0.0,accumulator-config.fixedDelta);++stats.fixedTicks;}
if(accumulator>=config.fixedDelta){auto remaining=std::fmod(accumulator,config.fixedDelta);stats.droppedTime=accumulator-remaining;accumulator=remaining;diagnostics.push_back("dropped_time="+std::to_string(stats.droppedTime));}
currentInput=input;phase=Phase::Update;all(&Behavior::update,step?config.fixedDelta:elapsed);
alpha=step?1.0:std::clamp(accumulator/config.fixedDelta,0.0,1.0);
for(auto [e,d]:registry.view<Pose>().each()){(void)e;d.presented=d.changedInUpdate?d.current:interpolate(d.previous,d.current,static_cast<float>(alpha));}
phase=Phase::Late;all(&Behavior::lateUpdate,step?config.fixedDelta:elapsed);phase=Phase::Idle;
stats.interpolationAlpha=alpha;stats.tick=tick;return stats;
}
};
Runtime::Runtime(RuntimeConfig cfg):impl_(std::make_unique<Impl>(this,cfg)) {
require(std::isfinite(cfg.fixedDelta)&&cfg.fixedDelta>0&&cfg.fixedDelta<=1,"invalid fixed delta");
require(cfg.maxCatchUpTicks>0&&cfg.maxCatchUpTicks<=1024,"invalid catchup limit");require(cfg.physicsSubsteps>0&&cfg.physicsSubsteps<=128,"invalid physics substeps");
for(float value:cfg.gravity)require(std::isfinite(value),"invalid gravity");
}
Runtime::~Runtime(){try{clear();}catch(...){}}
void Runtime::registerBehavior(std::string type,Behavior behavior) {
require(!impl_->busy&&impl_->ids.empty(),"register gameplay before loading scene");require(!type.empty()&&!impl_->behaviors.contains(type),"duplicate or empty behavior type");impl_->behaviors.emplace(std::move(type),std::move(behavior));
}
void Runtime::load(const Json& scene) {
require(!impl_->busy,"cannot load scene from gameplay callback");
require(scene.is_object()&&scene.value("format",std::string{})=="faset.scene"&&scene.value("version",0)==1,"unsupported scene format/version");
const int dimension=scene.value("dimension",3);require(dimension==2||dimension==3,"scene dimension must be 2 or 3");
require(scene.contains("entities")&&scene["entities"].is_array(),"scene entities must be an array");
require(!scene.contains("instances")||(scene["instances"].is_array()&&scene["instances"].empty()),"resolve template instances before runtime loading");
std::unordered_map<std::string,Json> entities;
for(const auto& entity:scene["entities"]) {validateEntity(entity,dimension);require(entities.emplace(entity["id"].get<std::string>(),entity).second,"duplicate scene entity id");}
for(const auto& [id,entity]:entities) {
std::set<std::string> visited{id};auto parent=entity.value("parent",Json{});
while(!parent.is_null()){auto key=parent.get<std::string>();require(entities.contains(key),"unknown parent entity");require(visited.insert(key).second,"cyclic parent hierarchy");parent=entities.at(key).value("parent",Json{});}
}
auto next=std::make_unique<Impl>(this,impl_->config);next->dimension=dimension;next->behaviors=impl_->behaviors;
next->physics=std::make_unique<detail::Physics>(dimension,next->config.gravity,next->config.physicsSubsteps);
for(const auto& entity:scene["entities"])next->create(entity);
clear();impl_=std::move(next);impl_->busy=true;impl_->phase=Impl::Phase::Initialize;impl_->all(&Behavior::onStart,0);impl_->phase=Impl::Phase::Idle;impl_->busy=false;
}
void Runtime::clear(){require(!impl_->busy,"cannot clear runtime from gameplay callback");impl_->busy=true;while(!impl_->order.empty())impl_->erase(impl_->order.back());impl_->pending.clear();impl_->contacts.clear();impl_->physics.reset();impl_->accumulator=0;impl_->tick=0;impl_->session=nextSession.fetch_add(1);impl_->busy=false;}
FrameStats Runtime::advance(double dt,InputState input){return impl_->frame(dt,input,false);}
FrameStats Runtime::singleStep(InputState input){return impl_->frame(0,input,true);}
void Runtime::setPaused(bool value){require(!impl_->busy,"pause control belongs outside gameplay callbacks");impl_->paused=value;impl_->accumulator=0;impl_->queuedInput={};impl_->alpha=0;for(auto [e,pose]:impl_->registry.view<Impl::Pose>().each()){(void)e;pose.previous=pose.presented=pose.current;}}
bool Runtime::paused()const noexcept{return impl_->paused;}
EntityHandle Runtime::find(const std::string& id)const{auto it=impl_->ids.find(id);return it==impl_->ids.end()?EntityHandle{}:impl_->handle(it->second);}
bool Runtime::valid(EntityHandle handle)const noexcept{return impl_->valid(handle);}
Transform Runtime::transform(EntityHandle h)const{return impl_->registry.get<Impl::Pose>(impl_->entity(h)).current;}
Transform Runtime::presentation(EntityHandle h)const{return impl_->registry.get<Impl::Pose>(impl_->entity(h)).presented;}
Json Runtime::fields(EntityHandle h,const std::string& type)const{auto c=impl_->component(impl_->entity(h),type);if(!c)throw std::invalid_argument("entity has no component: "+type);return (*c)["fields"];}
Vec3 Runtime::velocity(EntityHandle h)const{impl_->entity(h);if(!impl_->physics||!impl_->physics->contains(h.slot))throw std::invalid_argument("entity has no physics body");return impl_->physics->velocity(h.slot);}
InputState Runtime::input()const noexcept{return impl_->currentInput;}
const std::vector<CollisionEvent>& Runtime::collisions()const noexcept{return impl_->contacts;}
void Runtime::setTransform(EntityHandle h,const Transform& value){validateTransform(value);auto e=impl_->entity(h);if(impl_->physics&&impl_->physics->contains(h.slot))throw std::invalid_argument("physics transform requires teleport");auto& d=impl_->registry.get<Impl::Pose>(e);d.current=value;if(impl_->phase!=Impl::Phase::Fixed){d.previous=d.presented=value;d.changedInUpdate=true;}}
void Runtime::setPresentation(EntityHandle h,const Transform& value){validateTransform(value);require(impl_->phase==Impl::Phase::Late,"presentation may only be changed during LateUpdate");impl_->registry.get<Impl::Pose>(impl_->entity(h)).presented=value;}
void Runtime::teleport(EntityHandle h,const Transform& value){validateTransform(value);auto e=impl_->entity(h);auto& d=impl_->registry.get<Impl::Pose>(e);if(impl_->physics&&impl_->physics->contains(h.slot)){require(d.current.scale==value.scale,"changing collider scale requires remove/add body");if(impl_->dimension==2)require(value.rotation[0]==0&&value.rotation[1]==0,"2D physics rotates only around Z");impl_->physics->teleport(h.slot,value);}d.previous=d.current=d.presented=value;d.changedInUpdate=true;}
void Runtime::setVelocity(EntityHandle h,Vec3 value){impl_->entity(h);for(float v:value)require(std::isfinite(v),"nonfinite velocity");if(!impl_->physics||!impl_->physics->contains(h.slot))throw std::invalid_argument("entity has no physics body");impl_->physics->setVelocity(h.slot,value);}
void Runtime::applyImpulse(EntityHandle h,Vec3 value){impl_->entity(h);for(float v:value)require(std::isfinite(v),"nonfinite impulse");if(!impl_->physics||!impl_->physics->contains(h.slot))throw std::invalid_argument("entity has no physics body");impl_->physics->impulse(h.slot,value);}
void Runtime::spawn(Json entity){require(bool(impl_->physics),"load a scene before spawning");validateEntity(entity,impl_->dimension);impl_->pending.push_back({Impl::Kind::Spawn,{},std::move(entity),{}});}
void Runtime::destroy(EntityHandle h){impl_->entity(h);impl_->pending.push_back({Impl::Kind::Destroy,h,{},{}});}
void Runtime::addComponent(EntityHandle h,Json component){impl_->entity(h);impl_->pending.push_back({Impl::Kind::Add,h,std::move(component),{}});}
void Runtime::removeComponent(EntityHandle h,const std::string& type){impl_->entity(h);impl_->pending.push_back({Impl::Kind::Remove,h,{},type});}
RuntimeSnapshot Runtime::snapshot()const {
RuntimeSnapshot out{impl_->dimension,impl_->tick,impl_->alpha,{}};out.entities.reserve(impl_->order.size());
for(auto e:impl_->order) {const auto& d=impl_->registry.get<Impl::Data>(e);RenderEntity item;item.id=d.document["id"].get<std::string>();item.name=d.document.value("name",item.id);item.transform=impl_->registry.get<Impl::Pose>(e).presented;
if(d.document.contains("parent")&&!d.document["parent"].is_null())item.parent=d.document["parent"].get<std::string>();
if(auto sprite=impl_->registry.try_get<Sprite>(e))item.sprite=*sprite;
if(auto mesh=impl_->registry.try_get<Mesh>(e))item.mesh=*mesh;
out.entities.push_back(std::move(item));
}return out;
}
Json Runtime::snapshotJson()const{auto value=snapshot();Json entities=Json::array();for(const auto& e:value.entities){Json item{{"id",e.id},{"name",e.name},{"parent",e.parent?Json(*e.parent):Json{}},{"transform",transformJson(e.transform)}};if(e.sprite)item["sprite"]={{"color",e.sprite->color},{"size",e.sprite->size},{"texture",e.sprite->texture},{"layer",e.sprite->layer}};if(e.mesh)item["mesh"]={{"asset",e.mesh->asset},{"color",e.mesh->color},{"primitive",e.mesh->primitive}};const auto entity=impl_->ids.at(e.id);for(const auto& type:{"faset.camera","faset.light"})if(auto c=impl_->component(entity,type))item[type==std::string("faset.camera")?"camera":"light"]=(*c)["fields"];entities.push_back(std::move(item));}return {{"dimension",value.dimension},{"tick",value.tick},{"alpha",value.alpha},{"entities",entities}};}
std::uint64_t Runtime::session()const noexcept{return impl_->session;}
const std::vector<std::string>& Runtime::diagnostics()const noexcept{return impl_->diagnostics;}
}
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Tests actual GLB bundle publication without requiring Blender's GUI/Python runtime."""
import hashlib
import importlib.util
import json
from pathlib import Path
import struct
import tempfile
import sys
sys.dont_write_bytecode = True
import unittest
import uuid
MODULE = Path(__file__).resolve().parents[1] / "tools/blender_addon/bundle.py"
spec = importlib.util.spec_from_file_location("faset_bundle", MODULE)
bundle = importlib.util.module_from_spec(spec)
spec.loader.exec_module(bundle)
def glb(path, identity, name="Door", duplicate=False):
nodes = [{"name": name, "extras": {"faset_id": identity}}]
if duplicate:
nodes.append(nodes[0].copy())
doc = {"asset": {"version": "2.0"}, "scene": 0, "scenes": [{"nodes": [0]}], "nodes": nodes}
raw = json.dumps(doc).encode()
raw += b" " * (-len(raw) % 4)
path.write_bytes(struct.pack("<IIIII", 0x46546C67, 2, 20 + len(raw), len(raw), 0x4E4F534A) + raw)
class BundleTests(unittest.TestCase):
def test_atomic_roundtrip_and_duplicate_failure(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
identity, asset_id = str(uuid.uuid4()), str(uuid.uuid4())
source, out = root / "scene.glb", root / "published"
glb(source, identity)
first = bundle.publish_bundle(source, out, asset_id, "fixture")
payload = out / first["files"][0]["path"]
self.assertEqual(hashlib.sha256(payload.read_bytes()).hexdigest(), first["files"][0]["sha256"])
self.assertEqual(json.loads((out / "manifest.json").read_text()), first)
glb(source, identity, name="Renamed door")
second = bundle.publish_bundle(source, out, asset_id, "fixture")
self.assertNotEqual(first["generation"], second["generation"])
self.assertEqual(first["outputs"][0]["source_id"], second["outputs"][0]["source_id"])
self.assertTrue(payload.exists(), "old immutable payload prematurely destroyed")
previous = (out / "manifest.json").read_bytes()
glb(source, identity, duplicate=True)
with self.assertRaisesRegex(ValueError, "DuplicateSourceId"):
bundle.publish_bundle(source, out, asset_id, "fixture")
self.assertEqual((out / "manifest.json").read_bytes(), previous)
source.write_bytes(b"broken")
with self.assertRaises(ValueError):
bundle.publish_bundle(source, out, asset_id, "fixture")
self.assertEqual((out / "manifest.json").read_bytes(), previous)
def test_rejects_external_uri_before_publication(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
raw = json.dumps({"asset": {"version": "2.0"}, "buffers": [{"uri": "missing.bin", "byteLength": 4}]}).encode()
raw += b" " * (-len(raw) % 4)
source = root / "source.glb"
source.write_bytes(struct.pack("<IIIII", 0x46546C67, 2, 20 + len(raw), len(raw), 0x4E4F534A) + raw)
with self.assertRaisesRegex(ValueError, "embedded"):
bundle.publish_bundle(source, root / "out", str(uuid.uuid4()), "fixture")
self.assertFalse((root / "out" / "manifest.json").exists())
if __name__ == "__main__":
unittest.main()
+73
View File
@@ -0,0 +1,73 @@
#include <faset/assets/asset_pipeline.hpp>
#include <faset/core/hash.hpp>
#include <bit>
#include <chrono>
#include <fstream>
#include <iostream>
#include <stdexcept>
using namespace faset::assets;
namespace fs=std::filesystem;
namespace {
void require(bool value,const std::string& message){if(!value)throw std::runtime_error(message);}
void u32(std::vector<unsigned char>& data,std::uint32_t value){for(int i=0;i<4;++i)data.push_back(static_cast<unsigned char>(value>>(8*i)));}
void save(const fs::path& file,const std::vector<unsigned char>& data){std::ofstream out(file,std::ios::binary);out.write(reinterpret_cast<const char*>(data.data()),static_cast<std::streamsize>(data.size()));}
void save(const fs::path& file,const std::string& text){std::ofstream(file,std::ios::binary)<<text;}
std::vector<unsigned char> geometry(float x){std::vector<unsigned char> bin;for(float f:{x,0.f,0.f,1.f,0.f,0.f,0.f,1.f,0.f})u32(bin,std::bit_cast<std::uint32_t>(f));for(unsigned char c:{0,0,1,0,2,0})bin.push_back(c);return bin;}
Json document(const std::string& name,bool stable,bool second,float x=0) {
Json node{{"name",name},{"mesh",0}};if(stable)node["extras"]={{"faset_id","node-door"}};
Json j={{"asset",{{"version","2.0"}}},{"scene",0},{"scenes",Json::array({{{"nodes",Json::array({0})}}})},{"nodes",Json::array({node})},{"buffers",Json::array({{{"byteLength",42}}})},{"bufferViews",Json::array({{{"buffer",0},{"byteOffset",0},{"byteLength",36},{"target",34962}},{{"buffer",0},{"byteOffset",36},{"byteLength",6},{"target",34963}}})},{"accessors",Json::array({{{"bufferView",0},{"componentType",5126},{"count",3},{"type","VEC3"},{"min",{std::min(0.f,x),0,0}},{"max",{std::max(1.f,x),1,0}}},{{"bufferView",1},{"componentType",5123},{"count",3},{"type","SCALAR"}}})},{"meshes",Json::array({{{"name","Triangle"},{"extras",{{"faset_id","mesh-triangle"}}},{"primitives",Json::array({{{"attributes",{{"POSITION",0}}},{"indices",1},{"material",0}}})}}})},{"materials",Json::array({{{"name","Red"},{"pbrMetallicRoughness",{{"baseColorFactor",{1.0,0.2,0.1,1.0}},{"metallicFactor",0.2},{"roughnessFactor",0.6}}}}})}};
if(second){j["nodes"].push_back({{"name","Handle"},{"mesh",0},{"extras",{{"faset_id","node-handle"}}}});j["scenes"][0]["nodes"].push_back(1);}
return j;
}
void glb(const fs::path& path,const std::string& name="Door",bool stable=true,bool second=false,float x=0) {
auto json=document(name,stable,second,x).dump();while(json.size()%4)json+=' ';
auto binary=geometry(x);while(binary.size()%4)binary.push_back(0);
std::vector<unsigned char> result;u32(result,0x46546c67);u32(result,2);u32(result,static_cast<std::uint32_t>(12+8+json.size()+8+binary.size()));u32(result,static_cast<std::uint32_t>(json.size()));u32(result,0x4e4f534a);result.insert(result.end(),json.begin(),json.end());u32(result,static_cast<std::uint32_t>(binary.size()));u32(result,0x004e4942);result.insert(result.end(),binary.begin(),binary.end());save(path,result);
}
void success(const ImportResult& result){if(!result.ok()){std::string text="Import failed: ";for(const auto& d:result.diagnostics)text+=d+"; ";throw std::runtime_error(text);}}
}
int main() {
const auto root=fs::temp_directory_path()/("faset-assets-test-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()));
fs::create_directories(root);
try {
AssetPipeline pipeline(root/"cache");const auto source=root/"door.glb";glb(source);
auto first=pipeline.import_asset({source});success(first);require(!first.asset_id.empty(),"persistent identity missing");
auto asset=pipeline.load_asset(first.asset_id);require(asset.meshes.size()==1&&asset.nodes.size()==1,"mesh/node extraction");require(asset.meshes[0].primitives[0].indices==std::vector<std::uint32_t>({0,1,2}),"index extraction");require(asset.meshes[0].primitives[0].vertices[0].normal[2]==1,"generated normal");require(asset.materials[0].base_color[1]>.19f&&asset.materials[0].metallic==.2f,"PBR extraction");
const auto node_id=asset.nodes[0].id;
const Json custom{{node_id,{{"gameplay",{{"locked",true}}},{"physics",{{"mass",12}}},{"material","custom-brass"}}}};
pipeline.set_overrides(first.asset_id,custom);
auto unchanged=pipeline.import_asset({source});success(unchanged);require(unchanged.cache_hit&&unchanged.generation==first.generation,"content cache hit");
glb(source,"Renamed panel",true,false,.25f);auto modified=pipeline.import_asset({source});success(modified);require(modified.asset_id==first.asset_id&&modified.generation!=first.generation,"stable asset identity and changed generation");
asset=pipeline.load_asset(first.asset_id);require(asset.nodes[0].id==node_id&&asset.nodes[0].name=="Renamed panel","faset_id survives rename");require(asset.meshes[0].primitives[0].vertices[0].position[0]==.25f,"new binary geometry loaded");require(pipeline.overrides(first.asset_id)==custom,"reimport erased authoring overrides");
glb(source,"Renamed panel",true,true,.25f);auto added=pipeline.import_asset({source});success(added);
glb(source,"Renamed panel",true,false,.25f);auto removed=pipeline.import_asset({source});require(removed.status==ImportStatus::conflict&&!removed.removed_output_ids.empty(),"deletion must conflict");require(pipeline.load_asset(first.asset_id).generation==added.generation,"conflict replaced active generation");require(pipeline.overrides(first.asset_id)==custom,"conflict erased overrides");
ImportRequest resolve{source};resolve.allow_removed_outputs=true;auto resolved=pipeline.import_asset(resolve);success(resolved);require(pipeline.load_asset(first.asset_id).nodes.size()==1,"explicit deletion resolution");
const auto active=resolved.generation;
save(source,std::string("not a GLB"));auto failed=pipeline.import_asset({source});require(failed.status==ImportStatus::failed,"invalid source accepted");require(pipeline.load_asset(first.asset_id).generation==active,"failed import replaced active");
glb(source,"Renamed panel",true,false,.5f);ImportJob* job_ptr=nullptr;ImportJob job([&](const ImportProgress& p){if(p.fraction>=.9f)job_ptr->cancel();});job_ptr=&job;
auto cancelled=pipeline.import_asset({source},job);require(cancelled.status==ImportStatus::cancelled,"cancel before commit failed");require(pipeline.load_asset(first.asset_id).generation==active,"cancel replaced active");
ImportRequest changed_settings{source};changed_settings.settings={{"target","test-profile"}};auto settings=pipeline.import_asset(changed_settings);success(settings);require(settings.generation!=active,"recipe omitted from cache key");
const auto plain=root/"ordinary.glb";glb(plain,"Ordinary",false);auto standard=pipeline.import_asset({plain});success(standard);require(!pipeline.load_asset(standard.asset_id).nodes[0].stable_source_id,"ordinary GLB wrongly marked stable source");
glb(plain,"Renamed without ID",false);require(pipeline.import_asset({plain}).status==ImportStatus::conflict,"ambiguous rename silently matched");
auto duplicate=document("Duplicate",true,true);duplicate["nodes"][1]["extras"]["faset_id"]="node-door";duplicate["buffers"][0]["uri"]="mesh.bin";save(root/"mesh.bin",geometry(0));save(root/"duplicate.gltf",duplicate.dump());require(pipeline.import_asset({root/"duplicate.gltf"}).status==ImportStatus::failed,"duplicate source ID accepted");
auto external=document("External",true,false);external["buffers"][0]["uri"]="mesh.bin";external["images"]=Json::array({{{"uri","pixel.png"},{"mimeType","image/png"}}});external["textures"]=Json::array({{{"source",0}}});external["materials"][0]["pbrMetallicRoughness"]["baseColorTexture"]={{"index",0}};
// Real 1x1 PNG payload, transparent pixel; importer owns encoded bytes.
const std::vector<unsigned char> png={137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,11,73,68,65,84,120,156,99,96,0,2,0,0,5,0,1,165,246,69,64,0,0,0,0,73,69,78,68,174,66,96,130};
save(root/"pixel.png",png);save(root/"external.gltf",external.dump());auto ext=pipeline.import_asset({root/"external.gltf"});success(ext);auto ext_asset=pipeline.load_asset(ext.asset_id);require(ext_asset.textures.size()==1&&ext_asset.textures[0].bytes.size()==png.size(),"external image not extracted");require(ext_asset.materials[0].base_color_texture==0,"material texture reference lost");
save(root/"mesh.bin",geometry(.3f));auto dependent=pipeline.import_asset({root/"external.gltf"});success(dependent);require(dependent.generation!=ext.generation,"buffer dependency not invalidated");
// A Blender bundle keeps its logical source stable while immutable payload paths change.
const auto bundle_dir=root/"bundle";fs::create_directories(bundle_dir/"payload");
glb(bundle_dir/"payload"/"first.glb","Bundle",true,false);
Json bundle{{"schema_version",1},{"asset_id","bundle-asset"},{"files",Json::array({{{"path","payload/first.glb"},{"sha256",faset::sha256_file(bundle_dir/"payload"/"first.glb")}}})}};
save(bundle_dir/"manifest.json",bundle.dump());auto bundle_first=pipeline.import_asset({bundle_dir/"manifest.json"});success(bundle_first);require(bundle_first.asset_id=="bundle-asset","bundle identity lost");
glb(bundle_dir/"payload"/"second.glb","Bundle renamed",true,false,.4f);bundle["files"][0]={{"path","payload/second.glb"},{"sha256",faset::sha256_file(bundle_dir/"payload"/"second.glb")}};
save(bundle_dir/"manifest.json",bundle.dump());auto bundle_second=pipeline.import_asset({bundle_dir/"manifest.json"});success(bundle_second);require(bundle_second.asset_id==bundle_first.asset_id&&bundle_second.generation!=bundle_first.generation,"bundle reimport identity/generation");
bundle["files"][0]["sha256"]=std::string(64,'0');save(bundle_dir/"manifest.json",bundle.dump());require(pipeline.import_asset({bundle_dir/"manifest.json"}).status==ImportStatus::failed,"bundle checksum ignored");require(pipeline.load_asset("bundle-asset").generation==bundle_second.generation,"bad bundle replaced active");
// Changing a dependency after it was snapshotted cannot publish mixed content.
const auto dependency_active=pipeline.load_asset(ext.asset_id).generation;
ImportJob mutate([&](const ImportProgress& progress){if(progress.fraction==.5f)save(root/"mesh.bin",geometry(.7f));});
require(pipeline.import_asset({root/"external.gltf"},mutate).status==ImportStatus::failed,"concurrent dependency edit accepted");require(pipeline.load_asset(ext.asset_id).generation==dependency_active,"concurrent edit changed active");
fs::remove_all(root/"cache");auto restored=pipeline.import_asset({source});success(restored);require(restored.asset_id==first.asset_id,"cleared cache changed AssetId");require(restored.manifest["settings"]==changed_settings.settings,"persisted recipe lost");require(pipeline.overrides(first.asset_id)==custom,"cleared cache lost authoring overrides");
fs::remove_all(root);std::cout<<"assets: geometry/PBR/texture, GLB/glTF, cache, rename, deletion, overrides, failure, cancellation OK\n";return 0;
} catch(const std::exception& e){std::cerr<<e.what()<<"\nFixtures retained: "<<root<<'\n';return 1;}
}
+56
View File
@@ -0,0 +1,56 @@
#include <faset/authoring/service.hpp>
#include <faset/authoring/templates.hpp>
#include <faset/core/io.hpp>
#include <iostream>
#define CHECK(x) do {if(!(x))throw std::runtime_error("Check failed at line "+std::to_string(__LINE__)+": " #x);}while(false)
template<class Fn> void fails(Fn fn,const std::string& code) {try{fn();}catch(const faset::Error& error){CHECK(error.code()==code);return;}throw std::runtime_error("Expected error "+code);}
int main() {
using namespace faset;using namespace faset::authoring;
const auto root=std::filesystem::temp_directory_path()/("faset-authoring-"+new_id());
try {
auto schemas=builtin_schemas();AuthoringService service(root,schemas);
auto created=service.create("Courtyard",3);const std::string id=created["id"];
auto first=make_entity(schemas,"Door");const std::string entity_id=first["id"],transform_id=first["components"][0]["id"];
Json commands=Json::array({{{"op","entity.create"},{"entity",first}}});
const auto after=service.transact(id,0,commands,"request-1");CHECK(after["revision"]==1);CHECK(after["scene"]["entities"].size()==1);
CHECK(service.transact(id,0,commands,"request-1")==after);
fails([&]{service.transact(id,0,commands);},"revision.conflict");
fails([&]{service.transact(id,1,commands,"request-1");},"idempotency.conflict");
auto bad=Json::array({{{"op","entity.rename"},{"entity",entity_id},{"name","Should not survive"}},{{"op","component.set"},{"entity",entity_id},{"component",transform_id},{"field","position"},{"value","not a vector"}}});
fails([&]{service.transact(id,1,bad);},"validation.field_type");CHECK(service.query(id)==after);
auto edited=service.transact(id,1,Json::array({{{"op","component.set"},{"entity",entity_id},{"component",transform_id},{"field","position"},{"value",{4,0,2}}}}));
CHECK(edited["revision"]==2);CHECK(service.undo(id,2)["scene"]==after["scene"]);CHECK(service.redo(id,3)["scene"]==edited["scene"]);
CHECK(service.save(id,"Scenes/courtyard.scene.json")["dirty"]==false);
service.transact(id,4,Json::array({{{"op","entity.rename"},{"entity",entity_id},{"name","Дверь 世界"}}}));
AuthoringService restarted(root,schemas);const auto recovered=restarted.open("Scenes/courtyard.scene.json",true);CHECK(recovered["scene"]["entities"][0]["name"]=="Дверь 世界");CHECK(recovered["dirty"]==true);
atomic_write(root/"Scenes/courtyard.scene.json",read_text(root/"Scenes/courtyard.scene.json")+"\n");
fails([&]{restarted.save(id);},"save.disk_conflict");
// Parent cycles are rejected atomically; names never provide identity.
fails([&]{service.transact(id,5,Json::array({{{"op","entity.reparent"},{"entity",entity_id},{"parent",entity_id}}}));},"entity.cycle");
CHECK(service.query(id)["revision"]==5);
// Unavailable extension data is retained, including fields unknown to this SDK.
auto unknown=make_entity(schemas,"Plugin object");unknown["components"].push_back({{"id",new_id()},{"type","plugin.future"},{"version",5},{"fields",{{"unknown",Json::array({1,2,3})}}}});
service.transact(id,5,Json::array({{{"op","entity.create"},{"entity",unknown}}}));CHECK(service.query(id)["scene"]["entities"][1]==unknown);
// Nested template addresses remain valid after source rename and source reparent.
auto source=make_scene("Door template");source["entities"].push_back(first);
auto middle=make_scene("Nested");middle["instances"].push_back({{"id","nested"},{"source","door"}});
auto outer=make_scene("Level");
Json address={{"path",Json::array({"nested"})},{"object",entity_id},{"component",transform_id},{"field","position"}};
outer["instances"].push_back({{"id","one"},{"source","middle"},{"overrides",Json::array({{{"address",address},{"value",{8,0,0}}}})}});
outer["instances"].push_back({{"id","two"},{"source","middle"}});
auto loader=[&](const std::string& name){return name=="door"?source:middle;};
auto resolved=resolve_templates(outer,schemas,loader);CHECK(resolved.conflicts.empty());CHECK(resolved.scene["entities"].size()==2);
CHECK(resolved.scene["entities"][0]["components"][0]["fields"]["position"]==Json::array({8,0,0}));
CHECK(resolved.scene["entities"][1]["components"][0]["fields"]["position"]==Json::array({0,0,0}));
const auto stable=resolved.scene["entities"][0]["id"];source["entities"][0]["name"]="Renamed";
CHECK(resolve_templates(outer,schemas,loader).scene["entities"][0]["id"]==stable);
source["entities"]=Json::array();CHECK(resolve_templates(outer,schemas,loader).conflicts.size()==1);CHECK(outer["instances"][0]["overrides"].size()==1);
// Stable FieldId survives a label rename; incompatible migrations require an explicit decision.
SchemaRegistry newer;newer.register_schema({{"id","sample.type"},{"name","Sample"},{"version",2},{"fields",{{"speed",{{"id","speed"},{"name","Movement speed"},{"type","number"},{"default",2}}},{"enabled",{{"type","boolean"},{"default",true}}}}}});
newer.add_migration("sample.type",1,{{"speed",{{"scale",0.01}}}});
const auto migrated=newer.migrate_component({{"id",new_id()},{"type","sample.type"},{"version",1},{"fields",{{"speed",300},{"unrecognized","preserve"}}}});
CHECK(migrated["fields"]["speed"]==3.0);CHECK(migrated["fields"]["enabled"]==true);CHECK(migrated["fields"]["unrecognized"]=="preserve");
std::filesystem::remove_all(root);std::cout<<"Authoring transactions, conflict/retry, Undo, recovery, unknown fields, nested IDs and migrations passed\n";return 0;
} catch(const std::exception& error) {std::filesystem::remove_all(root);std::cerr<<error.what()<<'\n';return 1;}
}
+27
View File
@@ -0,0 +1,27 @@
#include <faset/core/hash.hpp>
#include <faset/core/io.hpp>
#include <faset/core/error.hpp>
#include <iostream>
#include <set>
#define CHECK(x) do { if(!(x)) throw std::runtime_error("Check failed: " #x); } while(false)
int main() {
const auto directory=std::filesystem::temp_directory_path()/("faset-core-"+faset::new_id());
try {
CHECK(faset::sha256("")=="e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
CHECK(faset::sha256("abc")=="ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
CHECK(faset::sha256(std::string(1000000,'a'))=="cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0");
std::set<std::string> ids;
for(int i=0;i<1000;++i) { const auto id=faset::new_id();CHECK(id.size()==36);CHECK(id[14]=='4');CHECK(ids.insert(id).second); }
faset::atomic_write(directory/"state.json","{\"value\":1}");
faset::atomic_write_json(directory/"state.json",{{"value",2},{"text","Привет 世界"}});
CHECK(faset::read_json(directory/"state.json").at("value")==2);
CHECK(faset::sha256_file(directory/"state.json")==faset::sha256(faset::read_text(directory/"state.json")));
CHECK(faset::project_path(directory,"assets/../state.json")==directory/"state.json");
bool rejected=false;try { faset::project_path(directory,"../escape"); } catch(const faset::Error&) {rejected=true;} CHECK(rejected);
rejected=false;try { faset::project_path(directory,directory/"state.json"); } catch(const faset::Error&) {rejected=true;} CHECK(rejected);
std::filesystem::remove_all(directory);
std::cout<<"Core: SHA-256 vectors, persistent IDs, durable replace, Unicode, path boundaries passed\n";
return 0;
} catch(const std::exception& error) {std::filesystem::remove_all(directory);std::cerr<<error.what()<<'\n';return 1;}
}
+26
View File
@@ -0,0 +1,26 @@
#include <faset/render/renderer.hpp>
#include <faset/render/render_graph.hpp>
#include <cmath>
#include <iostream>
#include <stdexcept>
using namespace faset::render;
void require(bool test,const char* message){if(!test)throw std::runtime_error(message);}
int main(int argc,char** argv){try{
if(argc>1&&std::string(argv[1])=="--unit"){
int count{};RenderGraph invalid;invalid.add("consumer",{"missing"},{},[&]{++count;});bool caught{};try{invalid.execute();}catch(const std::runtime_error&){caught=true;}require(caught&&count==0,"Graph must validate before side effects");RenderGraph graph;graph.import("external");graph.add("first",{"external"},{"color"},[&]{require(count==0,"Pass order");++count;});graph.add("second",{"color"},{},[&]{++count;});graph.execute();require(count==2,"Pass execution count");auto t=transform({2,3,4},{},{2,3,4});require(t[12]==2&&t[13]==3&&t[14]==4,"Transform translation");auto m=multiply(identity,t);require(m==t,"Matrix multiplication identity");require(cube_mesh()->indices.size()==36,"Cube triangle topology");std::cout<<"Render graph and math contracts passed\n";return 0;
}
bool visible=argc>1&&std::string(argv[1])=="--visible";
Renderer renderer({320,240,"Faset render validation",!visible,true});
Snapshot scene;scene.eye={4,3,5};scene.view_projection=multiply(perspective(.85f,320.f/240.f,.1f,100),look_at(scene.eye,{0,0,0}));scene.draws.push_back({cube_mesh(),transform({0,0,0}),{.2f,.65f,.95f,1},.4f,.15f,true});scene.draws.push_back({cube_mesh(),transform({0,-1,0},{},{8,.2f,8}),{.45f,.48f,.5f,1},.8f,0,true});scene.ui_quads.push_back({8,8,70,16,{.8f,.1f,.15f,1}});
auto texture=std::make_shared<Texture>();texture->width=texture->height=1;texture->rgba={20,220,40,255};scene.ui_quads.push_back({260,8,40,20,{1,1,1,1},texture});
renderer.render(scene);require(renderer.stats().validation_errors==0,"Vulkan validation reported an error");auto pixels=renderer.pixels();require(pixels.size()==320*240*4,"Readback dimensions");auto index=(10*320+10)*4;require(pixels[index]>190&&pixels[index+1]<50,"Colored UI pixel");index=(10*320+270)*4;require(pixels[index]<30&&pixels[index+1]>200,"Textured UI pixel");
auto shadowed=pixels;
if(argc>2)renderer.capture(std::string(argv[2])+".shadowed.ppm");
for(auto& draw:scene.draws)draw.cast_shadow=false;
renderer.render(scene);pixels=renderer.pixels();std::size_t shadow_difference{};for(std::size_t i=0;i<pixels.size();i+=4)if(pixels[i]>shadowed[i]+8)++shadow_difference;if(shadow_difference<=20){std::cerr<<"Shadow difference pixels: "<<shadow_difference<<"\n";if(argc>2)renderer.capture(std::string(argv[2])+".unshadowed.ppm");}require(shadow_difference>20,"Directional shadow must darken rendered surface pixels");
for(auto& draw:scene.draws)draw.cast_shadow=true;
std::string reload_error;require(renderer.reload_shaders(reload_error),"Compatible shader pipeline reload");
texture->rgba={40,30,230,255};++texture->revision;renderer.render(scene);pixels=renderer.pixels();require(pixels[index+2]>220,"Texture revision upload");
if(argc>2)renderer.capture(argv[2]);renderer.resize(400,300);renderer.poll_events();renderer.render(scene);require(renderer.width()==400&&renderer.height()==300,"Render target resize");require(renderer.stats().validation_errors==0,"Resize validation error");
std::cout<<"Vulkan frame, shadow/PBR, atlas upload, readback and resize passed on "<<renderer.stats().device<<'\n';
}catch(const std::exception& e){std::cerr<<e.what()<<'\n';return 1;}return 0;}
+89
View File
@@ -0,0 +1,89 @@
#include <faset/runtime/Runtime.hpp>
#include "Gameplay.hpp"
#include <cmath>
#include <iostream>
#include <limits>
#include <stdexcept>
#include <string>
#include <vector>
using namespace faset::runtime;
using Json=nlohmann::json;
namespace {
void check(bool result,const char* text){if(!result)throw std::runtime_error(text);}
void near(float actual,float expected,float tolerance,const char* text){check(std::abs(actual-expected)<tolerance,text);}
template<class F>void rejects(F&& fn,const char* message){bool caught=false;try{fn();}catch(const std::exception&){caught=true;}check(caught,message);}
Json component(std::string type,Json fields=Json::object()){return {{"id",type+"-id"},{"type",type},{"version",1},{"fields",fields}};}
Json entity(std::string id,float y=0){return {{"id",id},{"name",id},{"parent",nullptr},{"components",Json::array({component("faset.transform",{{"position",{0,y,0}}})})}};}
Json scene(int dim=2){return {{"format","faset.scene"},{"version",1},{"id","test-scene"},{"name","Test"},{"dimension",dim},{"entities",Json::array()},{"instances",Json::array()}};}
void physics(int dimension){
Runtime world;auto doc=scene(dimension);auto floor=entity("ground",-0.5f);auto falling=entity("falling",4);
auto type=dimension==2?"faset.rigid_body_2d":"faset.rigid_body_3d";
Json extents=dimension==2?Json{10,0.5}:Json{10,0.5,10};
floor["components"].push_back(component(type,{{"body_type","static"},{"half_extents",extents}}));
falling["components"].push_back(component(type));doc["entities"]={floor,falling};world.load(doc);auto h=world.find("falling");
bool contact=false;
for(int i=0;i<240;++i){world.advance(1.0/60);for(const auto& event:world.collisions())contact=contact||event.began;}
near(world.transform(h).position[1],0.5f,0.09f,"body must fall and settle on actual solver floor");check(contact,"native contact event must be delivered");
auto pose=world.transform(h);pose.position[1]=6;world.teleport(h,pose);
near(world.presentation(h).position[1],6,0.0001f,"teleport resets interpolation");
rejects([&]{world.setTransform(h,pose);},"physics transform cannot be casually overwritten");
world.applyImpulse(h,{0,2,0});check(world.velocity(h)[1]>0,"impulse changes solver velocity");
}
void lifecycle(){
Runtime world;std::vector<std::string> events;bool spawned=false;float presented=-1;int pressedTicks=0;
Behavior behavior;
behavior.onStart=[&](Runtime&,EntityHandle,double){events.push_back("start");};
behavior.fixedUpdate=[&](Runtime& r,EntityHandle h,double){events.push_back("fixed");if(r.input().jumpPressed)++pressedTicks;auto t=r.transform(h);t.position[0]+=1;r.setTransform(h,t);if(!spawned){r.spawn(entity("spawned"));spawned=true;}};
behavior.update=[&](Runtime&,EntityHandle,double){events.push_back("update");};
behavior.lateUpdate=[&](Runtime& r,EntityHandle h,double){events.push_back("late");presented=r.presentation(h).position[0];};
behavior.onDestroy=[&](Runtime& r,EntityHandle h,double){check(r.valid(h),"OnDestroy still sees a valid handle");events.push_back("destroy");};
world.registerBehavior("test.behavior",behavior);auto doc=scene();auto object=entity("main");object["components"].push_back(component("test.behavior"));doc["entities"].push_back(object);world.load(doc);
check(events==std::vector<std::string>{"start"},"load runs OnStart once");
world.advance(1.0/120,{0,0,true,false});check(!world.find("spawned"),"zero-tick frame applies no structural commands");
world.advance(1.0/60);check(!world.find("spawned"),"FixedUpdate spawn must wait until next tick");near(presented,0.5f,0.001f,"LateUpdate receives interpolated transform");check(pressedTicks==1,"input edge preserved across zero-tick frame");
world.advance(3.0/60);check(bool(world.find("spawned")),"spawn appears next tick");check(pressedTicks==1,"edge not repeated in catchup ticks");
auto old=world.find("main");world.destroy(old);check(world.valid(old),"destroy deferred");world.singleStep();check(!world.valid(old),"handle invalid after removal");
check(events.back()=="destroy","destroy lifecycle runs exactly at barrier");
world.spawn(entity("replacement"));world.singleStep();check(!world.valid(old),"reused slot never revives a stale handle");
auto replacement=world.find("replacement");world.load(doc);check(!world.valid(replacement),"load creates a new session");
// Ensure captured state remains alive while Runtime's destructor calls OnDestroy.
world.clear();
}
void clockAndValidation(){
Runtime world;auto doc=scene();doc["entities"].push_back(entity("object"));world.load(doc);
auto stats=world.advance(1.0);check(stats.fixedTicks==4,"catchup bounded to four ticks");check(stats.droppedTime>0.9,"excess time reported");check(stats.interpolationAlpha>=0&&stats.interpolationAlpha<1,"interpolation fraction bounded");
auto tick=stats.tick;world.setPaused(true);world.advance(100);check(world.snapshot().tick==tick,"pause does not accumulate");world.singleStep();check(world.snapshot().tick==tick+1,"single-step advances exactly once");world.setPaused(false);check(world.advance(0).fixedTicks==0,"resume does not catch up pause");
auto old=world.find("object");auto invalid=doc;invalid["entities"][0]["parent"]="object";rejects([&]{world.load(invalid);},"reject hierarchy cycle");check(world.valid(old),"invalid load preserves old world");
invalid=doc;invalid["entities"][0]["components"].push_back(component("faset.rigid_body_3d"));rejects([&]{world.load(invalid);},"reject physics dimension mismatch");
rejects([&]{world.advance(-1);},"reject negative time");
world.addComponent(old,component("faset.sprite"));check(!world.snapshot().entities[0].sprite,"component addition deferred");world.singleStep();check(world.snapshot().entities[0].sprite.has_value(),"component added at barrier");world.removeComponent(old,"faset.sprite");world.singleStep();check(!world.snapshot().entities[0].sprite,"component removed at barrier");
Runtime other;other.load(doc);check(!other.valid(old),"handle cannot cross worlds");
auto schema=faset::gameplay::schema();check(schema.size()==2,"sample has explicit metadata without world");
}
void structuralFailuresAndCallbacks(){
Runtime world;auto doc=scene();doc["entities"].push_back(entity("object"));world.load(doc);auto h=world.find("object");
world.addComponent(h,component("faset.sprite",{{"size",{-1,2}}}));world.singleStep();check(!world.snapshot().entities[0].sprite,"invalid deferred component leaves entity unchanged");check(!world.diagnostics().empty(),"invalid deferred command reports diagnostic");
auto zero=world.transform(h);zero.scale[0]=0;world.setTransform(h,zero);world.addComponent(h,component("faset.rigid_body_2d"));world.singleStep();rejects([&]{world.fields(h,"faset.rigid_body_2d");},"invalid runtime collider scale must not half-add component");
zero.scale[0]=1;world.setTransform(h,zero);world.addComponent(h,component("faset.rigid_body_2d"));world.singleStep();check(world.velocity(h)[1]<0,"deferred body runs actual physics");
world.removeComponent(h,"faset.rigid_body_2d");world.singleStep();rejects([&]{world.velocity(h);},"removed physics adapter no longer accessible");
world.destroy(h);world.destroy(h);world.singleStep();check(!world.valid(h),"repeated deferred destroy is safe");
rejects([&]{world.advance(std::numeric_limits<double>::quiet_NaN());},"nonfinite time rejected");
rejects([&]{world.advance(0,{std::numeric_limits<float>::infinity(),0,false,false});},"nonfinite input rejected");
Runtime callbacks;std::vector<std::string> order;
Behavior b;b.onStart=[&](Runtime& r,EntityHandle,double){order.push_back("start");rejects([&]{r.singleStep();},"OnStart cannot recursively advance");};
b.fixedUpdate=[&](Runtime&,EntityHandle,double){order.push_back("fixed");throw std::runtime_error("intentional callback failure");};
b.update=[&](Runtime&,EntityHandle,double){order.push_back("update");};
b.lateUpdate=[&](Runtime& r,EntityHandle h,double){order.push_back("late");auto p=r.presentation(h);p.position[2]=9;r.setPresentation(h,p);};
callbacks.registerBehavior("test",b);auto object=entity("callbacks");object["components"].push_back(component("test"));doc["entities"]={object};callbacks.load(doc);callbacks.singleStep();
check(order==std::vector<std::string>{"start","fixed","update","late"},"callback failure does not skip remaining phases");check(callbacks.diagnostics().size()==1,"callback exception diagnostic");near(callbacks.snapshot().entities[0].transform.position[2],9,0.001f,"LateUpdate changes final presentation only");near(callbacks.transform(callbacks.find("callbacks")).position[2],0,0.001f,"presentation does not overwrite simulation");callbacks.clear();
}
void sampleGameplay(){
Runtime world;faset::gameplay::registerGameplay(world);auto doc=scene(3);auto door=entity("door");door["components"].push_back(component("gameplay.door",{{"speed",2.0}}));doc["entities"]={door};world.load(doc);
world.advance(1.0/60,{0,0,false,true});for(int i=0;i<59;++i)world.advance(1.0/60);
near(world.transform(world.find("door")).rotation[1],1.5707963f,0.001f,"sample door opens through real static gameplay callback");
world.advance(1.0/60,{0,0,false,true});for(int i=0;i<59;++i)world.advance(1.0/60);
near(world.transform(world.find("door")).rotation[1],0,0.001f,"sample door toggles closed");
}
}
int main(){try{physics(2);physics(3);lifecycle();clockAndValidation();structuralFailuresAndCallbacks();sampleGameplay();std::cout<<"runtime contracts passed: actual Box2D/Box3D collisions, lifecycle, handles, interpolation, deferred mutation, catchup, pause, validation, gameplay\n";return 0;}catch(const std::exception& ex){std::cerr<<ex.what()<<'\n';return 1;}}
+11
View File
@@ -0,0 +1,11 @@
# Faset GLB helper
Optional add-on for the **unmodified official Blender**. Ordinary `.gltf`/`.glb` imports do not need it.
Zip this directory as `blender_addon/` and install the ZIP through Blender's add-on preferences. Enable **Faset GLB Export**, then use **File → Export → Faset GLB Bundle**. The chosen directory receives immutable `payload/<sha256>.glb` files and `manifest.json`, replaced only after a complete export. Save the `.blend` after the first export to persist assigned custom IDs. The first profile exports static geometry/PBR, without animation playback.
Objects, mesh datablocks and materials receive `faset_id` custom properties. Renaming an object preserves its ID. Ambiguous duplicate IDs stop publication. After deliberately duplicating an object, select the new copy and run **Faset: New IDs for Selected**. This changes object identity and duplicated mesh datablock identity; shared meshes stay shared. Material duplicates can be repaired explicitly in Custom Properties. Linked-library/generated data without persistent identity is outside this first profile.
The engine imports `manifest.json` or ordinary GLB/glTF. Gameplay components, physics settings and instance overrides are engine-owned data. The exporter does not write them. Without IDs, the engine does not promise reliable matching after renaming internal parts. Arbitrary procedural Blender materials require baking or an explicit engine material; this profile does not claim pixel-identical shading.
`bundle.py` is independent of Blender and has executable fixture tests. Blender UI/export execution still needs validation in an installed Blender version; this repository's tests do not substitute for that check.
+133
View File
@@ -0,0 +1,133 @@
"""Faset asset export helper for the unmodified official Blender application."""
bl_info = {
"name": "Faset GLB Export", "author": "Faset Engine", "version": (0, 1, 0),
"blender": (4, 2, 0), "location": "File > Export > Faset GLB Bundle",
"description": "Publish GLB with persistent custom IDs and an atomic manifest", "category": "Import-Export",
}
from pathlib import Path
import tempfile
import uuid
import bpy
from bpy.props import StringProperty
from bpy_extras.io_utils import ExportHelper
from .bundle import publish_bundle
def ensure_persistent_ids(context):
"""Assign only missing IDs. Ambiguous duplicates require an explicit user operation."""
objects = list(context.scene.objects)
meshes = list({obj.data for obj in objects if obj.type == "MESH"})
materials = list({slot.material for obj in objects for slot in obj.material_slots if slot.material})
groups = [objects, meshes, materials]
for group in groups:
seen = {}
for block in group:
if block.library:
raise ValueError(f"Linked data needs local IDs before export: {block.name}")
identity = block.get("faset_id")
if not identity:
identity = str(uuid.uuid4())
block["faset_id"] = identity
try:
uuid.UUID(identity)
except (ValueError, TypeError, AttributeError) as error:
raise ValueError(f"Invalid faset_id on {block.name}") from error
if identity in seen:
raise ValueError(f"DuplicateSourceId: {seen[identity]} / {block.name}. "
"Select the new copy and run Faset: New IDs for Selected.")
seen[identity] = block.name
if not context.scene.get("faset_asset_id"):
context.scene["faset_asset_id"] = str(uuid.uuid4())
return context.scene["faset_asset_id"]
class FASET_OT_new_selected_ids(bpy.types.Operator):
bl_idname = "faset.new_selected_ids"
bl_label = "Faset: New IDs for Selected"
bl_description = "Explicitly fork object identities; shared mesh/material identities remain shared"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return bool(context.selected_objects)
def execute(self, context):
selected = set(context.selected_objects)
for obj in selected:
if obj.library:
self.report({"ERROR"}, "Linked objects must be made local first")
return {"CANCELLED"}
obj["faset_id"] = str(uuid.uuid4())
# A copied mesh owns its new identity; a genuinely shared mesh stays shared.
for obj in selected:
mesh = obj.data if obj.type == "MESH" else None
if mesh and not mesh.library:
duplicate = any(other is not mesh and other.get("faset_id") == mesh.get("faset_id")
for other in bpy.data.meshes)
if duplicate:
mesh["faset_id"] = str(uuid.uuid4())
return {"FINISHED"}
class FASET_OT_export(bpy.types.Operator, ExportHelper):
bl_idname = "export_scene.faset_bundle"
bl_label = "Faset GLB Bundle"
filename_ext = ".json"
filter_glob: StringProperty(default="*.json", options={"HIDDEN"})
def execute(self, context):
frame, subframe = context.scene.frame_current, context.scene.frame_subframe
active = context.view_layer.objects.active
selected = list(context.selected_objects)
mode = active.mode if active else "OBJECT"
try:
asset_id = ensure_persistent_ids(context)
directory = Path(self.filepath).resolve().parent
with tempfile.TemporaryDirectory(prefix="faset-export-") as temporary:
payload = Path(temporary) / "scene.glb"
result = bpy.ops.export_scene.gltf(filepath=str(payload), export_format="GLB",
export_extras=True, export_yup=True, export_animations=False,
export_materials="EXPORT", use_selection=False, use_active_scene=True)
if "FINISHED" not in result:
raise RuntimeError("Blender glTF export did not finish")
manifest = publish_bundle(payload, directory, asset_id,
bpy.app.version_string, bpy.data.filepath)
self.report({"INFO"}, f"Published {manifest['generation'][:12]}; save .blend to persist IDs")
return {"FINISHED"}
except Exception as error:
self.report({"ERROR"}, str(error))
return {"CANCELLED"}
finally:
context.scene.frame_set(frame, subframe=subframe)
for obj in context.selected_objects:
obj.select_set(False)
for obj in selected:
if obj.name in context.view_layer.objects:
obj.select_set(True)
if active and active.name in context.view_layer.objects:
context.view_layer.objects.active = active
if active.mode != mode:
try:
bpy.ops.object.mode_set(mode=mode)
except RuntimeError:
pass
def menu_export(self, context):
self.layout.operator(FASET_OT_export.bl_idname, text="Faset GLB Bundle (.json)")
def register():
bpy.utils.register_class(FASET_OT_new_selected_ids)
bpy.utils.register_class(FASET_OT_export)
bpy.types.TOPBAR_MT_file_export.append(menu_export)
def unregister():
bpy.types.TOPBAR_MT_file_export.remove(menu_export)
bpy.utils.unregister_class(FASET_OT_export)
bpy.utils.unregister_class(FASET_OT_new_selected_ids)
if __name__ == "__main__":
register()
+98
View File
@@ -0,0 +1,98 @@
"""Pure-Python GLB bundle publication; no Blender import, usable in unit tests."""
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
import struct
import tempfile
import uuid
def read_glb(path: Path) -> dict:
raw = path.read_bytes()
if len(raw) < 20:
raise ValueError("Truncated GLB")
magic, version, size = struct.unpack_from("<III", raw)
if magic != 0x46546C67 or version != 2 or size != len(raw):
raise ValueError("Invalid GLB 2 header")
cursor, document = 12, None
while cursor < len(raw):
if cursor + 8 > len(raw):
raise ValueError("Truncated GLB chunk header")
length, kind = struct.unpack_from("<II", raw, cursor)
cursor += 8
if length % 4 or cursor + length > len(raw):
raise ValueError("Invalid GLB chunk")
if kind == 0x4E4F534A:
if document is not None:
raise ValueError("Duplicate GLB JSON chunk")
document = json.loads(raw[cursor:cursor + length])
cursor += length
if document is None or document.get("asset", {}).get("version") != "2.0":
raise ValueError("Missing glTF 2 document")
return document
def _write_atomic(path: Path, raw: bytes) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary = tempfile.mkstemp(prefix=".faset-", dir=path.parent)
try:
with os.fdopen(descriptor, "wb") as stream:
stream.write(raw)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
def publish_bundle(glb: Path, directory: Path, asset_id: str,
blender_version: str, source_hint: str = "") -> dict:
"""Immutable payload first, atomic manifest last; failures preserve previous manifest."""
uuid.UUID(asset_id)
document = read_glb(glb)
for collection in ("buffers", "images"):
for entry in document.get(collection, []):
uri = entry.get("uri", "")
if uri and not uri.startswith("data:"):
raise ValueError("Bundle exporter requires embedded GLB dependencies")
outputs, seen = [], set()
for kind, collection in (("node", "nodes"), ("mesh", "meshes"), ("material", "materials")):
for index, item in enumerate(document.get(collection, [])):
identity = item.get("extras", {}).get("faset_id")
# Some generated exporter subresources have no persistent datablock.
if identity is None:
if kind == "node" and "mesh" in item:
raise ValueError("Exported mesh node has no faset_id; enable custom properties")
continue
uuid.UUID(identity)
key = (kind, identity)
if key in seen:
raise ValueError(f"DuplicateSourceId: {kind} {identity}")
seen.add(key)
outputs.append({"source_id": identity, "kind": kind, "name": item.get("name", ""),
"locator": f"/{collection}/{index}"})
raw = glb.read_bytes()
digest = hashlib.sha256(raw).hexdigest()
payload = f"payload/{digest}.glb"
manifest = {
"schema_version": 1, "asset_id": asset_id,
"source": {"path_hint": source_hint},
"exporter": {"blender_version": blender_version, "addon_version": "0.1.0"},
"recipe": {"profile": "faset-gltf-static-v1", "export_extras": True,
"export_yup": True, "export_animations": False},
"files": [{"path": payload, "sha256": digest, "size": len(raw)}],
"outputs": outputs,
}
canonical = json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode()
manifest["generation"] = hashlib.sha256(canonical).hexdigest()
destination = directory / payload
if destination.exists():
if hashlib.sha256(destination.read_bytes()).hexdigest() != digest:
raise ValueError("Existing immutable payload is corrupt")
else:
_write_atomic(destination, raw)
_write_atomic(directory / "manifest.json", json.dumps(manifest, sort_keys=True, indent=2).encode())
return manifest
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""Download pinned source archives once; CMake reuses them without network access."""
import argparse
import concurrent.futures
import hashlib
import json
from pathlib import Path
import urllib.request
ROOT = Path(__file__).resolve().parents[1]
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--verify-only", action="store_true")
args = parser.parse_args()
lock = json.loads((ROOT / "dependencies.lock.json").read_text())
directory = ROOT / ".cache" / "downloads"
directory.mkdir(parents=True, exist_ok=True)
def fetch(item):
name, dep = item
path = directory / f"{name}-{dep['commit']}.tar.gz"
if not path.exists():
if args.verify_only:
raise RuntimeError(f"Missing archive: {path}")
with urllib.request.urlopen(dep["url"], timeout=180) as response:
content = response.read()
if hashlib.sha256(content).hexdigest() != dep["sha256"]:
raise RuntimeError(f"Checksum mismatch: {name}")
temporary = path.with_suffix(".download")
temporary.write_bytes(content)
temporary.replace(path)
if hashlib.sha256(path.read_bytes()).hexdigest() != dep["sha256"]:
raise RuntimeError(f"Checksum mismatch: {name}")
print(f"Verified {name}: {dep['commit']}", flush=True)
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
list(pool.map(fetch, lock["dependencies"].items()))
if __name__ == "__main__":
main()
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Fetch the pinned Slang compiler and verify its upstream release digest."""
import argparse
import hashlib
import pathlib
import platform
import tarfile
import urllib.request
import zipfile
VERSION = '2026.18'
PACKAGES = {
'Linux': ('linux-x86_64-glibc-2.28.tar.gz', '8f27819f6bce2e37f3549e204b57a954d8daee67a5a5735cdc437b8bc7b87a50'),
'Windows': ('windows-x86_64.zip', '6ffa4827b519fd0a85b38407049d87ab0c1f045fe2289cb1e6831f965169f8a1'),
}
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--output', type=pathlib.Path, default=pathlib.Path('.cache/slang'))
args = parser.parse_args()
if platform.machine().lower() not in ('x86_64', 'amd64'):
raise SystemExit('Pinned Slang packages currently support x86-64 hosts; supply SLANGC_EXECUTABLE for another host.')
suffix, digest = PACKAGES[platform.system()]
name = f'slang-{VERSION}-{suffix}'
args.output.mkdir(parents=True, exist_ok=True)
archive = args.output / name
if not archive.exists():
url = f'https://github.com/shader-slang/slang/releases/download/v{VERSION}/{name}'
temporary = archive.with_suffix('.download')
urllib.request.urlretrieve(url, temporary)
temporary.replace(archive)
actual = hashlib.sha256(archive.read_bytes()).hexdigest()
if actual != digest:
raise SystemExit(f'Slang checksum mismatch for {archive}; expected {digest}, received {actual}')
if suffix.endswith('.zip'):
with zipfile.ZipFile(archive) as package:
for item in package.infolist():
target = (args.output / item.filename).resolve()
if not target.is_relative_to(args.output.resolve()):
raise SystemExit('Unsafe archive path')
package.extractall(args.output)
else:
with tarfile.open(archive) as package:
package.extractall(args.output, filter='data')
executable = args.output / 'bin' / ('slangc.exe' if platform.system() == 'Windows' else 'slangc')
if not executable.is_file():
raise SystemExit(f'Compiler missing from package: {executable}')
print(executable.resolve())
if __name__ == '__main__':
main()