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
+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;}
}