Checkpoint 2: integrate native Editor, MCP, gameplay builds and standalone export
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <faset/assets/asset_data.hpp>
|
||||
#include <faset/player/SceneView.hpp>
|
||||
#include <limits>
|
||||
#include <numbers>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
#include <unordered_map>
|
||||
#if defined(FASET_HAS_STB)
|
||||
#define STB_IMAGE_IMPLEMENTATION
|
||||
#define STBI_NO_STDIO
|
||||
#include <stb_image.h>
|
||||
#endif
|
||||
|
||||
namespace faset::player {
|
||||
namespace {
|
||||
using Json = nlohmann::json;
|
||||
Json properties(const Json& entity, const std::string& name) {
|
||||
if (entity.contains(name))
|
||||
return entity.at(name);
|
||||
if (entity.contains("components"))
|
||||
for (const auto& c : entity["components"])
|
||||
if (c.at("type") == "faset." + name)
|
||||
return c.at("fields");
|
||||
return Json{};
|
||||
}
|
||||
template <std::size_t N>
|
||||
std::array<float, N> vec(const Json& value, const char* name, std::array<float, N> fallback) {
|
||||
if (value.is_null() || !value.contains(name))
|
||||
return fallback;
|
||||
auto result = value.at(name).get<std::array<float, N>>();
|
||||
for (float v : result)
|
||||
if (!std::isfinite(v))
|
||||
throw std::runtime_error("nonfinite scene vector");
|
||||
return result;
|
||||
}
|
||||
render::Vec3 point(const render::Mat4& m, render::Vec3 p) {
|
||||
return {m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12],
|
||||
m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13],
|
||||
m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14]};
|
||||
}
|
||||
render::Vec3 direction(const render::Mat4& m, render::Vec3 p) {
|
||||
return {m[0] * p[0] + m[4] * p[1] + m[8] * p[2], m[1] * p[0] + m[5] * p[1] + m[9] * p[2],
|
||||
m[2] * p[0] + m[6] * p[1] + m[10] * p[2]};
|
||||
}
|
||||
std::pair<std::string, std::string> reference(const std::string& ref) {
|
||||
const auto hash = ref.find('#');
|
||||
return {ref.substr(0, hash), hash == std::string::npos ? std::string{} : ref.substr(hash + 1)};
|
||||
}
|
||||
std::shared_ptr<const render::Mesh> plane() {
|
||||
static const auto mesh = []() {
|
||||
auto out = std::make_shared<render::Mesh>();
|
||||
out->vertices = {{{-.5f, 0, -.5f}, {0, 1, 0}, {1, 1, 1, 1}, {0, 0}},
|
||||
{{.5f, 0, -.5f}, {0, 1, 0}, {1, 1, 1, 1}, {1, 0}},
|
||||
{{.5f, 0, .5f}, {0, 1, 0}, {1, 1, 1, 1}, {1, 1}},
|
||||
{{-.5f, 0, .5f}, {0, 1, 0}, {1, 1, 1, 1}, {0, 1}}};
|
||||
out->indices = {0, 2, 1, 0, 3, 2};
|
||||
return out;
|
||||
}();
|
||||
return mesh;
|
||||
}
|
||||
} // namespace
|
||||
struct SceneView::Impl {
|
||||
struct Bundle {
|
||||
assets::CookedAsset data;
|
||||
std::vector<std::vector<std::shared_ptr<const render::Mesh>>> meshes;
|
||||
std::vector<std::shared_ptr<const render::Texture>> textures;
|
||||
};
|
||||
assets::AssetStore pipeline;
|
||||
std::unordered_map<std::string, Bundle> bundles;
|
||||
std::vector<std::string> messages;
|
||||
explicit Impl(std::filesystem::path path) : pipeline(std::move(path)) {}
|
||||
Bundle& bundle(const std::string& id) {
|
||||
if (auto it = bundles.find(id); it != bundles.end())
|
||||
return it->second;
|
||||
Bundle result;
|
||||
result.data = pipeline.load_asset(id);
|
||||
for (const auto& mesh : result.data.meshes) {
|
||||
auto& primitives = result.meshes.emplace_back();
|
||||
for (const auto& primitive : mesh.primitives) {
|
||||
auto converted = std::make_shared<render::Mesh>();
|
||||
converted->indices = primitive.indices;
|
||||
converted->vertices.reserve(primitive.vertices.size());
|
||||
for (const auto& vertex : primitive.vertices)
|
||||
converted->vertices.push_back(
|
||||
{vertex.position, vertex.normal, {1, 1, 1, 1}, vertex.uv});
|
||||
primitives.push_back(std::move(converted));
|
||||
}
|
||||
}
|
||||
for (const auto& texture : result.data.textures) {
|
||||
std::shared_ptr<render::Texture> converted;
|
||||
#if defined(FASET_HAS_STB)
|
||||
if (texture.bytes.size() > std::size_t(std::numeric_limits<int>::max()))
|
||||
throw std::runtime_error("Encoded texture exceeds decoder limit");
|
||||
int width = 0, height = 0, channels = 0;
|
||||
if (!stbi_info_from_memory(reinterpret_cast<const unsigned char*>(texture.bytes.data()),
|
||||
static_cast<int>(texture.bytes.size()), &width, &height,
|
||||
&channels))
|
||||
throw std::runtime_error("Cannot read texture dimensions: " + texture.id);
|
||||
if (width <= 0 || height <= 0 || width > 16384 || height > 16384 ||
|
||||
std::uint64_t(width) * std::uint64_t(height) > 64 * 1024 * 1024)
|
||||
throw std::runtime_error("Texture exceeds decoder image limits");
|
||||
auto pixels = stbi_load_from_memory(
|
||||
reinterpret_cast<const unsigned char*>(texture.bytes.data()),
|
||||
static_cast<int>(texture.bytes.size()), &width, &height, &channels, 4);
|
||||
if (!pixels)
|
||||
throw std::runtime_error("Cannot decode texture " + texture.id + ": " +
|
||||
std::string(stbi_failure_reason() ? stbi_failure_reason()
|
||||
: "unsupported image"));
|
||||
std::unique_ptr<unsigned char, decltype(&stbi_image_free)> guard(pixels,
|
||||
&stbi_image_free);
|
||||
if (width <= 0 || height <= 0 || width > 16384 || height > 16384)
|
||||
throw std::runtime_error("Texture exceeds 16384 dimension limit");
|
||||
converted = std::make_shared<render::Texture>();
|
||||
converted->width = width;
|
||||
converted->height = height;
|
||||
converted->srgb = true;
|
||||
converted->rgba.assign(pixels, pixels + std::size_t(width) * std::size_t(height) * 4);
|
||||
#else
|
||||
throw std::runtime_error("Image decoding was not enabled for this Player build");
|
||||
#endif
|
||||
result.textures.push_back(std::move(converted));
|
||||
}
|
||||
return bundles.emplace(id, std::move(result)).first->second;
|
||||
}
|
||||
std::shared_ptr<const render::Texture> texture(const std::string& ref) {
|
||||
auto [id, selector] = reference(ref);
|
||||
auto& asset = bundle(id);
|
||||
if (asset.textures.empty())
|
||||
throw std::runtime_error("Asset has no texture: " + ref);
|
||||
if (selector.empty())
|
||||
return asset.textures.front();
|
||||
for (std::size_t i = 0; i < asset.data.textures.size(); ++i)
|
||||
if (asset.data.textures[i].id == selector)
|
||||
return asset.textures[i];
|
||||
throw std::runtime_error("Texture subasset does not exist: " + ref);
|
||||
}
|
||||
void imported(render::Snapshot& out, const std::string& ref, const render::Mat4& model,
|
||||
render::Color tint) {
|
||||
const auto [id, selector] = reference(ref);
|
||||
auto& asset = bundle(id);
|
||||
auto emit = [&](std::size_t meshIndex, const render::Mat4& local) {
|
||||
if (meshIndex >= asset.meshes.size())
|
||||
throw std::runtime_error("Invalid cooked mesh index");
|
||||
for (std::size_t p = 0; p < asset.meshes[meshIndex].size(); ++p) {
|
||||
render::DrawItem draw;
|
||||
draw.mesh = asset.meshes[meshIndex][p];
|
||||
draw.model = render::multiply(model, local);
|
||||
draw.color = tint;
|
||||
const auto material = asset.data.meshes[meshIndex].primitives[p].material;
|
||||
if (material >= 0) {
|
||||
if (std::size_t(material) >= asset.data.materials.size())
|
||||
throw std::runtime_error("Invalid cooked material index");
|
||||
const auto& m = asset.data.materials[material];
|
||||
for (int i = 0; i < 4; ++i)
|
||||
draw.color[i] *= m.base_color[i];
|
||||
draw.roughness = m.roughness;
|
||||
draw.metallic = m.metallic;
|
||||
if (m.base_color_texture >= 0) {
|
||||
if (std::size_t(m.base_color_texture) >= asset.textures.size())
|
||||
throw std::runtime_error("Invalid base-color texture index");
|
||||
draw.texture = asset.textures[m.base_color_texture];
|
||||
}
|
||||
if (m.normal_texture >= 0 || m.metallic_roughness_texture >= 0 ||
|
||||
m.alpha_mode != "OPAQUE" || m.unlit || m.double_sided)
|
||||
messages.push_back(
|
||||
"warning: material " + m.id +
|
||||
" has features beyond the initial base-color/PBR renderer");
|
||||
}
|
||||
out.draws.push_back(std::move(draw));
|
||||
}
|
||||
};
|
||||
if (!selector.empty())
|
||||
for (std::size_t i = 0; i < asset.data.meshes.size(); ++i)
|
||||
if (asset.data.meshes[i].id == selector) {
|
||||
emit(i, render::identity);
|
||||
return;
|
||||
}
|
||||
std::unordered_map<std::string, const assets::Node*> nodes;
|
||||
for (const auto& node : asset.data.nodes)
|
||||
nodes.emplace(node.id, &node);
|
||||
if (!selector.empty() && !nodes.contains(selector))
|
||||
throw std::runtime_error("Node/mesh subasset does not exist: " + ref);
|
||||
std::unordered_map<std::string, render::Mat4> matrices;
|
||||
std::set<std::string> active;
|
||||
auto world = [&](auto&& self, const assets::Node& node) -> render::Mat4 {
|
||||
if (auto it = matrices.find(node.id); it != matrices.end())
|
||||
return it->second;
|
||||
if (!active.insert(node.id).second)
|
||||
throw std::runtime_error("Cyclic cooked node hierarchy");
|
||||
auto matrix = node.local_transform;
|
||||
if (!node.parent_id.empty()) {
|
||||
auto parent = nodes.find(node.parent_id);
|
||||
if (parent == nodes.end())
|
||||
throw std::runtime_error("Missing cooked parent node");
|
||||
matrix = render::multiply(self(self, *parent->second), matrix);
|
||||
}
|
||||
active.erase(node.id);
|
||||
return matrices.emplace(node.id, matrix).first->second;
|
||||
};
|
||||
if (asset.data.nodes.empty())
|
||||
for (std::size_t i = 0; i < asset.meshes.size(); ++i)
|
||||
emit(i, render::identity);
|
||||
for (const auto& node : asset.data.nodes)
|
||||
if (node.mesh >= 0) {
|
||||
bool selected = selector.empty();
|
||||
auto current = &node;
|
||||
std::set<std::string> seen;
|
||||
while (!selected && current && seen.insert(current->id).second) {
|
||||
selected = current->id == selector;
|
||||
auto parent = nodes.find(current->parent_id);
|
||||
current = parent == nodes.end() ? nullptr : parent->second;
|
||||
}
|
||||
if (selected)
|
||||
emit(static_cast<std::size_t>(node.mesh), world(world, node));
|
||||
}
|
||||
}
|
||||
};
|
||||
SceneView::SceneView(std::filesystem::path cacheRoot)
|
||||
: impl_(std::make_unique<Impl>(std::move(cacheRoot))) {}
|
||||
SceneView::~SceneView() = default;
|
||||
void SceneView::clearCache() {
|
||||
impl_->bundles.clear();
|
||||
}
|
||||
const std::vector<std::string>& SceneView::diagnostics() const {
|
||||
return impl_->messages;
|
||||
}
|
||||
render::Snapshot SceneView::build(const Json& scene, float aspect, CameraSettings camera) {
|
||||
if (!std::isfinite(aspect) || aspect <= 0)
|
||||
throw std::invalid_argument("Viewport aspect must be positive");
|
||||
impl_->messages.clear();
|
||||
render::Snapshot out;
|
||||
const auto& entities = scene.at("entities");
|
||||
if (!entities.is_array())
|
||||
throw std::invalid_argument("Scene entities must be an array");
|
||||
std::unordered_map<std::string, const Json*> byId;
|
||||
for (const auto& entity : entities)
|
||||
if (!byId.emplace(entity.at("id").get<std::string>(), &entity).second)
|
||||
throw std::invalid_argument("Duplicate scene ID");
|
||||
std::unordered_map<std::string, render::Mat4> matrices;
|
||||
std::set<std::string> active;
|
||||
auto world = [&](auto&& self, const Json& entity) -> render::Mat4 {
|
||||
auto id = entity.at("id").get<std::string>();
|
||||
if (auto it = matrices.find(id); it != matrices.end())
|
||||
return it->second;
|
||||
if (!active.insert(id).second)
|
||||
throw std::invalid_argument("Cyclic scene hierarchy");
|
||||
const auto fields = properties(entity, "transform");
|
||||
auto matrix = render::transform(vec<3>(fields, "position", {0, 0, 0}),
|
||||
vec<3>(fields, "rotation", {0, 0, 0}),
|
||||
vec<3>(fields, "scale", {1, 1, 1}));
|
||||
if (entity.contains("parent") && !entity["parent"].is_null()) {
|
||||
auto parent = byId.find(entity["parent"].get<std::string>());
|
||||
if (parent == byId.end())
|
||||
throw std::invalid_argument("Missing scene parent");
|
||||
matrix = render::multiply(self(self, *parent->second), matrix);
|
||||
}
|
||||
active.erase(id);
|
||||
return matrices.emplace(id, matrix).first->second;
|
||||
};
|
||||
const int dimension = scene.value("dimension", 3);
|
||||
if (dimension != 2 && dimension != 3)
|
||||
throw std::invalid_argument("Scene dimension must be 2 or 3");
|
||||
render::Vec3 cameraUp{0, 1, 0};
|
||||
bool foundCamera = false;
|
||||
std::vector<std::pair<int, render::Sprite>> sprites;
|
||||
for (const auto& entity : entities) {
|
||||
const auto model = world(world, entity);
|
||||
if (auto fields = properties(entity, "camera");
|
||||
!fields.is_null() && !camera.overrideSceneCamera && !foundCamera) {
|
||||
camera.eye = point(model, {0, 0, 0});
|
||||
camera.target = point(model, {0, 0, -1});
|
||||
cameraUp = direction(model, {0, 1, 0});
|
||||
camera.verticalFovDegrees = fields.value("fov", 60.0f);
|
||||
camera.nearPlane = fields.value("near", 0.1f);
|
||||
camera.farPlane = fields.value("far", 1000.0f);
|
||||
foundCamera = true;
|
||||
}
|
||||
if (auto fields = properties(entity, "light"); !fields.is_null())
|
||||
out.light_direction = direction(model, {-0.5f, -1, -0.3f});
|
||||
if (auto fields = properties(entity, "sprite"); !fields.is_null()) {
|
||||
render::Sprite sprite;
|
||||
sprite.position = point(model, {0, 0, 0});
|
||||
auto size = vec<2>(fields, "size", {1, 1});
|
||||
float sx = std::hypot(model[0], model[1]), sy = std::hypot(model[4], model[5]);
|
||||
sprite.size = {size[0] * sx, size[1] * sy};
|
||||
sprite.rotation = std::atan2(model[1], model[0]);
|
||||
sprite.color = vec<4>(fields, "color", {1, 1, 1, 1});
|
||||
if (model[0] * model[5] - model[1] * model[4] < 0)
|
||||
sprite.size[1] = -sprite.size[1];
|
||||
if (sx > 0 && sy > 0 &&
|
||||
std::abs((model[0] * model[4] + model[1] * model[5]) / (sx * sy)) > 0.0001f)
|
||||
impl_->messages.push_back("warning: sprite hierarchy shear is approximated");
|
||||
auto texture = fields.value("texture", std::string{});
|
||||
if (!texture.empty())
|
||||
try {
|
||||
sprite.texture = impl_->texture(texture);
|
||||
} catch (const std::exception& e) {
|
||||
impl_->messages.push_back("error: " + std::string(e.what()));
|
||||
sprite.color = {1, 0, 1, 1};
|
||||
}
|
||||
sprites.emplace_back(fields.value("layer", 0), std::move(sprite));
|
||||
}
|
||||
if (auto fields = properties(entity, "mesh"); !fields.is_null()) {
|
||||
const auto tint = vec<4>(fields, "color", {1, 1, 1, 1});
|
||||
const auto asset = fields.value("asset", std::string{});
|
||||
if (asset.empty() || asset.starts_with("builtin:")) {
|
||||
const auto primitive = asset.empty()
|
||||
? fields.value("primitive", std::string("cube"))
|
||||
: asset.substr(8);
|
||||
if (primitive != "plane" && primitive != "cube")
|
||||
throw std::invalid_argument("Unsupported builtin mesh: " + primitive);
|
||||
out.draws.push_back({primitive == "plane" ? plane() : render::cube_mesh(),
|
||||
model,
|
||||
tint,
|
||||
0.65f,
|
||||
0.0f,
|
||||
true,
|
||||
{}});
|
||||
} else
|
||||
try {
|
||||
impl_->imported(out, asset, model, tint);
|
||||
} catch (const std::exception& e) {
|
||||
impl_->messages.push_back("error: " + std::string(e.what()));
|
||||
out.draws.push_back(
|
||||
{render::cube_mesh(), model, {1, 0, 1, 1}, 0.65f, 0.0f, true, {}});
|
||||
}
|
||||
}
|
||||
}
|
||||
std::stable_sort(sprites.begin(), sprites.end(),
|
||||
[](const auto& a, const auto& b) { return a.first < b.first; });
|
||||
for (auto& pair : sprites)
|
||||
out.sprites.push_back(std::move(pair.second));
|
||||
if (dimension == 2) {
|
||||
const float height = camera.orthographicHeight;
|
||||
if (!std::isfinite(height) || height <= 0)
|
||||
throw std::invalid_argument("Orthographic height must be positive");
|
||||
const auto center = foundCamera ? camera.eye : camera.target;
|
||||
out.view_projection =
|
||||
render::multiply(render::orthographic(-height * aspect / 2, height * aspect / 2,
|
||||
-height / 2, height / 2, -100, 100),
|
||||
render::transform({-center[0], -center[1], 0}));
|
||||
out.eye = {center[0], center[1], 10};
|
||||
} else {
|
||||
if (!std::isfinite(camera.verticalFovDegrees) || camera.verticalFovDegrees <= 0 ||
|
||||
camera.verticalFovDegrees >= 179)
|
||||
throw std::invalid_argument("Camera FOV out of range");
|
||||
if (!std::isfinite(camera.nearPlane) || !std::isfinite(camera.farPlane) ||
|
||||
camera.nearPlane <= 0 || camera.farPlane <= camera.nearPlane)
|
||||
throw std::invalid_argument("Camera depth range is invalid");
|
||||
float distance = 0;
|
||||
render::Vec3 delta{};
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
if (!std::isfinite(camera.eye[i]) || !std::isfinite(camera.target[i]) ||
|
||||
!std::isfinite(cameraUp[i]))
|
||||
throw std::invalid_argument("Camera basis must be finite");
|
||||
delta[i] = camera.target[i] - camera.eye[i];
|
||||
distance += delta[i] * delta[i];
|
||||
}
|
||||
const render::Vec3 cross{delta[1] * cameraUp[2] - delta[2] * cameraUp[1],
|
||||
delta[2] * cameraUp[0] - delta[0] * cameraUp[2],
|
||||
delta[0] * cameraUp[1] - delta[1] * cameraUp[0]};
|
||||
if (distance < 1e-10f ||
|
||||
cross[0] * cross[0] + cross[1] * cross[1] + cross[2] * cross[2] < 1e-10f)
|
||||
throw std::invalid_argument("Camera basis is degenerate");
|
||||
out.eye = camera.eye;
|
||||
out.view_projection = render::multiply(
|
||||
render::perspective(camera.verticalFovDegrees * std::numbers::pi_v<float> / 180, aspect,
|
||||
camera.nearPlane, camera.farPlane),
|
||||
render::look_at(camera.eye, camera.target, cameraUp));
|
||||
}
|
||||
std::sort(impl_->messages.begin(), impl_->messages.end());
|
||||
impl_->messages.erase(std::unique(impl_->messages.begin(), impl_->messages.end()),
|
||||
impl_->messages.end());
|
||||
return out;
|
||||
}
|
||||
} // namespace faset::player
|
||||
Reference in New Issue
Block a user