feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
- nodes, with their parents;
- every mesh's primitives: positions, normals, UV0, tangents (computed
when absent), joints, weights (normalized), u32 indices;
- skins with their inverse-bind matrices;
- materials: PBR, emissive strength, transmission, ior, alpha mode;
- images decoded to RGBA8, and samplers;
- cameras and KHR_lights_punctual lights;
- the raw JSON, and the extras of the scene, nodes, materials and lights.
nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
- bad magic, truncation, a sparse accessor;
- a chunk, view or accessor past its bounds, checked so that nothing
wraps round, with 32-bit size_t too;
- a stride outside 4..252, or one its elements overlap in;
- a reference to something that doesn't exist, or children that form a
cycle (found stepping through each node once, however deep);
- a path that isn't a file;
- an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
LoadPNG refuses images over 65535 a side instead of truncating their size
(in its float path, past the end of the pixels).
- Tests: GLB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
dfd7f899cf
commit
c04b7a331a
7 changed files with 27170 additions and 6 deletions
694
implementations/Crafter.Asset-GLB.cpp
Normal file
694
implementations/Crafter.Asset-GLB.cpp
Normal file
|
|
@ -0,0 +1,694 @@
|
||||||
|
//SPDX-License-Identifier: LGPL-3.0-only
|
||||||
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
module;
|
||||||
|
// nlohmann/json stays inside this unit. Included from the interface, it
|
||||||
|
// would be parsed again by everything that imports Crafter.Asset.
|
||||||
|
#include "../lib/json.hpp"
|
||||||
|
module Crafter.Asset;
|
||||||
|
import std;
|
||||||
|
import Crafter.Math;
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
// Json, not json: GlbAsset has a member called json, which would hide it
|
||||||
|
// inside GlbAsset::Load.
|
||||||
|
using Json = nlohmann::json;
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr std::uint32_t kMagic = 0x46546C67; // "glTF"
|
||||||
|
constexpr std::uint32_t kChunkJson = 0x4E4F534A; // "JSON"
|
||||||
|
constexpr std::uint32_t kChunkBin = 0x004E4942; // "BIN\0"
|
||||||
|
|
||||||
|
constexpr std::uint32_t kByte = 5120;
|
||||||
|
constexpr std::uint32_t kUnsignedByte = 5121;
|
||||||
|
constexpr std::uint32_t kShort = 5122;
|
||||||
|
constexpr std::uint32_t kUnsignedShort = 5123;
|
||||||
|
constexpr std::uint32_t kUnsignedInt = 5125;
|
||||||
|
constexpr std::uint32_t kFloat = 5126;
|
||||||
|
|
||||||
|
std::unexpected<std::string> Error(std::string message) {
|
||||||
|
return std::unexpected("GLB: " + std::move(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads from the parsed JSON that never throw (a -fno-exceptions build
|
||||||
|
// aborts on a throw): a missing value or one of the wrong type reads as
|
||||||
|
// the fallback.
|
||||||
|
const Json* Find(const Json& object, const char* key) {
|
||||||
|
if (!object.is_object()) return nullptr;
|
||||||
|
const auto it = object.find(key);
|
||||||
|
return it == object.end() ? nullptr : &*it;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::int64_t Integer(const Json& object, const char* key, std::int64_t fallback) {
|
||||||
|
const Json* value = Find(object, key);
|
||||||
|
return value != nullptr && value->is_number_integer() ? value->get<std::int64_t>() : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
float Number(const Json& object, const char* key, float fallback) {
|
||||||
|
const Json* value = Find(object, key);
|
||||||
|
return value != nullptr && value->is_number() ? value->get<float>() : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Boolean(const Json& object, const char* key, bool fallback) {
|
||||||
|
const Json* value = Find(object, key);
|
||||||
|
return value != nullptr && value->is_boolean() ? value->get<bool>() : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string String(const Json& object, const char* key) {
|
||||||
|
const Json* value = Find(object, key);
|
||||||
|
return value != nullptr && value->is_string() ? value->get<std::string>() : std::string();
|
||||||
|
}
|
||||||
|
|
||||||
|
const Json& Array(const Json& object, const char* key) {
|
||||||
|
static const Json empty = Json::array();
|
||||||
|
const Json* value = Find(object, key);
|
||||||
|
return value != nullptr && value->is_array() ? *value : empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// An object's extras as JSON text, or empty when it has none.
|
||||||
|
std::string Extras(const Json& object) {
|
||||||
|
const Json* value = Find(object, "extras");
|
||||||
|
return value != nullptr ? value->dump() : std::string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The object an extension keeps in `object`'s extensions, or null.
|
||||||
|
const Json* Extension(const Json& object, const char* name) {
|
||||||
|
const Json* extensions = Find(object, "extensions");
|
||||||
|
return extensions != nullptr ? Find(*extensions, name) : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Up to N numbers from a JSON array into `out`, which keeps its values
|
||||||
|
// where the array is shorter or holds something else.
|
||||||
|
template <std::size_t N>
|
||||||
|
void Numbers(const Json& object, const char* key, std::array<float, N>& out) {
|
||||||
|
const Json& values = Array(object, key);
|
||||||
|
for (std::size_t i = 0; i < N && i < values.size(); ++i) {
|
||||||
|
if (values[i].is_number()) out[i] = values[i].get<float>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t ComponentSize(std::uint32_t componentType) {
|
||||||
|
switch (componentType) {
|
||||||
|
case kByte: case kUnsignedByte: return 1;
|
||||||
|
case kShort: case kUnsignedShort: return 2;
|
||||||
|
case kUnsignedInt: case kFloat: return 4;
|
||||||
|
default: return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::uint32_t ComponentCount(const std::string& type) {
|
||||||
|
if (type == "SCALAR") return 1;
|
||||||
|
if (type == "VEC2") return 2;
|
||||||
|
if (type == "VEC3") return 3;
|
||||||
|
if (type == "VEC4") return 4;
|
||||||
|
if (type == "MAT4") return 16;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// An accessor resolved to its bytes in the BIN chunk.
|
||||||
|
struct Accessor {
|
||||||
|
const std::byte* data;
|
||||||
|
std::size_t count;
|
||||||
|
std::size_t stride;
|
||||||
|
std::uint32_t componentType;
|
||||||
|
std::uint32_t components;
|
||||||
|
bool normalized;
|
||||||
|
|
||||||
|
float Float(std::size_t element, std::uint32_t component) const {
|
||||||
|
const std::byte* p = data + element * stride + component * ComponentSize(componentType);
|
||||||
|
switch (componentType) {
|
||||||
|
case kFloat: { float v; std::memcpy(&v, p, sizeof(v)); return v; }
|
||||||
|
case kUnsignedByte: { std::uint8_t v; std::memcpy(&v, p, sizeof(v)); return normalized ? v / 255.0f : v; }
|
||||||
|
case kUnsignedShort: { std::uint16_t v; std::memcpy(&v, p, sizeof(v)); return normalized ? v / 65535.0f : v; }
|
||||||
|
case kByte: { std::int8_t v; std::memcpy(&v, p, sizeof(v)); return normalized ? std::max(v / 127.0f, -1.0f) : v; }
|
||||||
|
case kShort: { std::int16_t v; std::memcpy(&v, p, sizeof(v)); return normalized ? std::max(v / 32767.0f, -1.0f) : v; }
|
||||||
|
case kUnsignedInt: { std::uint32_t v; std::memcpy(&v, p, sizeof(v)); return static_cast<float>(v); }
|
||||||
|
default: return 0.0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::uint32_t Unsigned(std::size_t element, std::uint32_t component) const {
|
||||||
|
const std::byte* p = data + element * stride + component * ComponentSize(componentType);
|
||||||
|
switch (componentType) {
|
||||||
|
case kUnsignedByte: { std::uint8_t v; std::memcpy(&v, p, sizeof(v)); return v; }
|
||||||
|
case kUnsignedShort: { std::uint16_t v; std::memcpy(&v, p, sizeof(v)); return v; }
|
||||||
|
case kUnsignedInt: { std::uint32_t v; std::memcpy(&v, p, sizeof(v)); return v; }
|
||||||
|
default: return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Context {
|
||||||
|
const Json& root;
|
||||||
|
std::span<const std::byte> bin;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The bytes of buffer view `index`, which must lie in the BIN chunk.
|
||||||
|
std::expected<std::span<const std::byte>, std::string> ViewBytes(const Context& ctx, std::int64_t index, std::size_t* strideOut) {
|
||||||
|
const Json& views = Array(ctx.root, "bufferViews");
|
||||||
|
if (index < 0 || static_cast<std::size_t>(index) >= views.size()) {
|
||||||
|
return Error(std::format("bufferView {} doesn't exist", index));
|
||||||
|
}
|
||||||
|
const Json& view = views[static_cast<std::size_t>(index)];
|
||||||
|
if (Integer(view, "buffer", -1) != 0) {
|
||||||
|
return Error(std::format("bufferView {} uses an external buffer, which a GLB loader doesn't read", index));
|
||||||
|
}
|
||||||
|
const std::int64_t offset = Integer(view, "byteOffset", 0);
|
||||||
|
const std::int64_t length = Integer(view, "byteLength", -1);
|
||||||
|
// Compared so that nothing can overflow.
|
||||||
|
const std::uint64_t size = ctx.bin.size();
|
||||||
|
if (offset < 0 || length < 0 || static_cast<std::uint64_t>(offset) > size || static_cast<std::uint64_t>(length) > size - static_cast<std::uint64_t>(offset)) {
|
||||||
|
return Error(std::format("bufferView {} lies outside the BIN chunk", index));
|
||||||
|
}
|
||||||
|
// glTF allows 4 to 252 bytes between elements; 0 or none packs them.
|
||||||
|
const std::int64_t stride = Integer(view, "byteStride", 0);
|
||||||
|
if (stride != 0 && (stride < 4 || stride > 252)) {
|
||||||
|
return Error(std::format("bufferView {} has a byteStride of {}, outside 4..252", index, stride));
|
||||||
|
}
|
||||||
|
if (strideOut != nullptr) *strideOut = static_cast<std::size_t>(stride);
|
||||||
|
return ctx.bin.subspan(static_cast<std::size_t>(offset), static_cast<std::size_t>(length));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::expected<Accessor, std::string> ReadAccessor(const Context& ctx, std::int64_t index) {
|
||||||
|
const Json& accessors = Array(ctx.root, "accessors");
|
||||||
|
if (index < 0 || static_cast<std::size_t>(index) >= accessors.size()) {
|
||||||
|
return Error(std::format("accessor {} doesn't exist", index));
|
||||||
|
}
|
||||||
|
const Json& accessor = accessors[static_cast<std::size_t>(index)];
|
||||||
|
if (Find(accessor, "sparse") != nullptr) {
|
||||||
|
return Error(std::format("accessor {} is sparse, which isn't supported", index));
|
||||||
|
}
|
||||||
|
const std::uint32_t componentType = static_cast<std::uint32_t>(Integer(accessor, "componentType", 0));
|
||||||
|
const std::uint32_t components = ComponentCount(String(accessor, "type"));
|
||||||
|
if (ComponentSize(componentType) == 0 || components == 0) {
|
||||||
|
return Error(std::format("accessor {} has an unsupported type or componentType", index));
|
||||||
|
}
|
||||||
|
// Matrices of 1- and 2-byte components pad their columns; nothing
|
||||||
|
// here reads those.
|
||||||
|
if (components == 16 && ComponentSize(componentType) != 4) {
|
||||||
|
return Error(std::format("accessor {} is a MAT4 of small components, which isn't supported", index));
|
||||||
|
}
|
||||||
|
const std::int64_t count = Integer(accessor, "count", -1);
|
||||||
|
if (count < 0) return Error(std::format("accessor {} has no count", index));
|
||||||
|
if (Find(accessor, "bufferView") == nullptr) {
|
||||||
|
return Error(std::format("accessor {} has no bufferView", index));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t viewStride = 0;
|
||||||
|
const auto view = ViewBytes(ctx, Integer(accessor, "bufferView", -1), &viewStride);
|
||||||
|
if (!view) return std::unexpected(view.error());
|
||||||
|
const std::size_t elementSize = ComponentSize(componentType) * components;
|
||||||
|
if (viewStride != 0 && viewStride < elementSize) {
|
||||||
|
return Error(std::format("accessor {}'s elements overlap: {} bytes in a stride of {}", index, elementSize, viewStride));
|
||||||
|
}
|
||||||
|
const std::size_t stride = viewStride != 0 ? viewStride : elementSize;
|
||||||
|
const std::int64_t offset = Integer(accessor, "byteOffset", 0);
|
||||||
|
// The last element ends inside the view, compared so that nothing
|
||||||
|
// can overflow, whatever the count.
|
||||||
|
const std::uint64_t size = view->size();
|
||||||
|
const bool inside = offset >= 0 && static_cast<std::uint64_t>(offset) <= size
|
||||||
|
&& (count == 0 || (elementSize <= size - static_cast<std::uint64_t>(offset)
|
||||||
|
&& static_cast<std::uint64_t>(count - 1) <= (size - static_cast<std::uint64_t>(offset) - elementSize) / stride));
|
||||||
|
if (!inside) {
|
||||||
|
return Error(std::format("accessor {} reads past the end of its bufferView", index));
|
||||||
|
}
|
||||||
|
return Accessor{
|
||||||
|
.data = view->data() + offset,
|
||||||
|
.count = static_cast<std::size_t>(count),
|
||||||
|
.stride = stride,
|
||||||
|
.componentType = componentType,
|
||||||
|
.components = components,
|
||||||
|
.normalized = Boolean(accessor, "normalized", false),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// An accessor of `components` per element whose component type is one of
|
||||||
|
// `allowed`, with exactly `count` elements unless count is 0.
|
||||||
|
std::expected<Accessor, std::string> Attribute(const Context& ctx, const Json& attributes, const char* name, std::uint32_t components,
|
||||||
|
std::initializer_list<std::uint32_t> allowed, std::size_t count, const std::string& where) {
|
||||||
|
auto accessor = ReadAccessor(ctx, Integer(attributes, name, -1));
|
||||||
|
if (!accessor) return std::unexpected(std::format("{} ({} of {})", accessor.error(), name, where));
|
||||||
|
if (accessor->components != components || std::ranges::find(allowed, accessor->componentType) == allowed.end()) {
|
||||||
|
return Error(std::format("{} of {} has the wrong type", name, where));
|
||||||
|
}
|
||||||
|
if (count != 0 && accessor->count != count) {
|
||||||
|
return Error(std::format("{} of {} has {} elements, POSITION has {}", name, where, accessor->count, count));
|
||||||
|
}
|
||||||
|
return accessor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-vertex tangents from the UV gradients, accumulated over the
|
||||||
|
// triangles a vertex is in and made orthogonal to its normal. glTF's v
|
||||||
|
// runs down the image while a normal map's +Y points up, so its
|
||||||
|
// bitangent is the one along -v: hence the sign below, the same
|
||||||
|
// correction three.js applies to MikkTSpace output for glTF.
|
||||||
|
void ComputeTangents(GlbPrimitive& p) {
|
||||||
|
using V3 = Vector<float, 3, 0>;
|
||||||
|
const std::size_t n = p.positions.size();
|
||||||
|
std::vector<V3> tangentSum(n, V3(0, 0, 0));
|
||||||
|
std::vector<V3> bitangentSum(n, V3(0, 0, 0));
|
||||||
|
for (std::size_t t = 0; t + 2 < p.indices.size(); t += 3) {
|
||||||
|
const std::uint32_t i[3] = {p.indices[t], p.indices[t + 1], p.indices[t + 2]};
|
||||||
|
const V3 p0(p.positions[i[0]].x, p.positions[i[0]].y, p.positions[i[0]].z);
|
||||||
|
const V3 e1 = V3(p.positions[i[1]].x, p.positions[i[1]].y, p.positions[i[1]].z) - p0;
|
||||||
|
const V3 e2 = V3(p.positions[i[2]].x, p.positions[i[2]].y, p.positions[i[2]].z) - p0;
|
||||||
|
const float du1 = p.uv0[i[1]].x - p.uv0[i[0]].x, dv1 = p.uv0[i[1]].y - p.uv0[i[0]].y;
|
||||||
|
const float du2 = p.uv0[i[2]].x - p.uv0[i[0]].x, dv2 = p.uv0[i[2]].y - p.uv0[i[0]].y;
|
||||||
|
const float determinant = du1 * dv2 - du2 * dv1;
|
||||||
|
if (std::abs(determinant) < 1e-12f) continue; // degenerate UVs carry no direction
|
||||||
|
const float f = 1.0f / determinant;
|
||||||
|
const V3 tangent = (e1 * dv2 - e2 * dv1) * f;
|
||||||
|
const V3 bitangent = (e2 * du1 - e1 * du2) * f;
|
||||||
|
for (std::uint32_t v : i) {
|
||||||
|
tangentSum[v] += tangent;
|
||||||
|
bitangentSum[v] += bitangent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.tangents.resize(n);
|
||||||
|
for (std::size_t v = 0; v < n; ++v) {
|
||||||
|
const V3 normal(p.normals[v].x, p.normals[v].y, p.normals[v].z);
|
||||||
|
V3 tangent = tangentSum[v] - normal * V3::Dot(normal, tangentSum[v]);
|
||||||
|
if (tangent.LengthSq() < 1e-20f) {
|
||||||
|
// No usable UV gradient: any direction perpendicular to the
|
||||||
|
// normal keeps the frame valid.
|
||||||
|
tangent = V3::Cross(std::abs(normal.x) < 0.9f ? V3(1, 0, 0) : V3(0, 1, 0), normal);
|
||||||
|
}
|
||||||
|
tangent.Normalize();
|
||||||
|
const float sign = V3::Dot(V3::Cross(normal, tangent), bitangentSum[v]) < 0 ? 1.0f : -1.0f;
|
||||||
|
p.tangents[v] = Vector<float, 4, 4>(tangent.x, tangent.y, tangent.z, sign);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::expected<GlbPrimitive, std::string> ReadPrimitive(const Context& ctx, const Json& primitive, const std::string& where) {
|
||||||
|
if (Integer(primitive, "mode", 4) != 4) {
|
||||||
|
return Error(std::format("{} is not a triangle list (mode {})", where, Integer(primitive, "mode", 4)));
|
||||||
|
}
|
||||||
|
const Json* attributes = Find(primitive, "attributes");
|
||||||
|
if (attributes == nullptr || Find(*attributes, "POSITION") == nullptr) {
|
||||||
|
return Error(std::format("{} has no POSITION", where));
|
||||||
|
}
|
||||||
|
|
||||||
|
GlbPrimitive p;
|
||||||
|
const auto positions = Attribute(ctx, *attributes, "POSITION", 3, {kFloat}, 0, where);
|
||||||
|
if (!positions) return std::unexpected(positions.error());
|
||||||
|
const std::size_t n = positions->count;
|
||||||
|
p.positions.reserve(n);
|
||||||
|
for (std::size_t v = 0; v < n; ++v) {
|
||||||
|
p.positions.emplace_back(positions->Float(v, 0), positions->Float(v, 1), positions->Float(v, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Find(*attributes, "NORMAL") != nullptr) {
|
||||||
|
const auto normals = Attribute(ctx, *attributes, "NORMAL", 3, {kFloat}, n, where);
|
||||||
|
if (!normals) return std::unexpected(normals.error());
|
||||||
|
p.normals.reserve(n);
|
||||||
|
for (std::size_t v = 0; v < n; ++v) {
|
||||||
|
p.normals.emplace_back(normals->Float(v, 0), normals->Float(v, 1), normals->Float(v, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Find(*attributes, "TEXCOORD_0") != nullptr) {
|
||||||
|
const auto uv = Attribute(ctx, *attributes, "TEXCOORD_0", 2, {kFloat, kUnsignedByte, kUnsignedShort}, n, where);
|
||||||
|
if (!uv) return std::unexpected(uv.error());
|
||||||
|
p.uv0.reserve(n);
|
||||||
|
for (std::size_t v = 0; v < n; ++v) {
|
||||||
|
p.uv0.emplace_back(uv->Float(v, 0), uv->Float(v, 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Find(*attributes, "TANGENT") != nullptr) {
|
||||||
|
const auto tangents = Attribute(ctx, *attributes, "TANGENT", 4, {kFloat}, n, where);
|
||||||
|
if (!tangents) return std::unexpected(tangents.error());
|
||||||
|
p.tangents.reserve(n);
|
||||||
|
for (std::size_t v = 0; v < n; ++v) {
|
||||||
|
p.tangents.emplace_back(tangents->Float(v, 0), tangents->Float(v, 1), tangents->Float(v, 2), tangents->Float(v, 3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Find(*attributes, "JOINTS_0") != nullptr) {
|
||||||
|
const auto joints = Attribute(ctx, *attributes, "JOINTS_0", 4, {kUnsignedByte, kUnsignedShort}, n, where);
|
||||||
|
if (!joints) return std::unexpected(joints.error());
|
||||||
|
p.joints.resize(n);
|
||||||
|
for (std::size_t v = 0; v < n; ++v) {
|
||||||
|
for (std::uint32_t c = 0; c < 4; ++c) p.joints[v][c] = static_cast<std::uint16_t>(joints->Unsigned(v, c));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Find(*attributes, "WEIGHTS_0") != nullptr) {
|
||||||
|
const auto weights = Attribute(ctx, *attributes, "WEIGHTS_0", 4, {kFloat, kUnsignedByte, kUnsignedShort}, n, where);
|
||||||
|
if (!weights) return std::unexpected(weights.error());
|
||||||
|
p.weights.resize(n);
|
||||||
|
for (std::size_t v = 0; v < n; ++v) {
|
||||||
|
float sum = 0;
|
||||||
|
for (std::uint32_t c = 0; c < 4; ++c) {
|
||||||
|
p.weights[v][c] = weights->Float(v, c);
|
||||||
|
sum += p.weights[v][c];
|
||||||
|
}
|
||||||
|
if (sum > 0) {
|
||||||
|
for (float& w : p.weights[v]) w /= sum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Find(primitive, "indices") != nullptr) {
|
||||||
|
const auto indices = ReadAccessor(ctx, Integer(primitive, "indices", -1));
|
||||||
|
if (!indices) return std::unexpected(std::format("{} (indices of {})", indices.error(), where));
|
||||||
|
if (indices->components != 1 || (indices->componentType != kUnsignedByte && indices->componentType != kUnsignedShort
|
||||||
|
&& indices->componentType != kUnsignedInt)) {
|
||||||
|
return Error(std::format("indices of {} have the wrong type", where));
|
||||||
|
}
|
||||||
|
p.indices.reserve(indices->count);
|
||||||
|
for (std::size_t i = 0; i < indices->count; ++i) p.indices.push_back(indices->Unsigned(i, 0));
|
||||||
|
} else {
|
||||||
|
p.indices.resize(n);
|
||||||
|
std::iota(p.indices.begin(), p.indices.end(), 0u);
|
||||||
|
}
|
||||||
|
if (p.indices.size() % 3 != 0) {
|
||||||
|
return Error(std::format("{} has {} indices, not whole triangles", where, p.indices.size()));
|
||||||
|
}
|
||||||
|
if (std::ranges::any_of(p.indices, [n](std::uint32_t i) { return i >= n; })) {
|
||||||
|
return Error(std::format("{} indexes past its {} vertices", where, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
p.material = static_cast<std::int32_t>(Integer(primitive, "material", -1));
|
||||||
|
if (p.tangents.empty() && !p.normals.empty() && !p.uv0.empty()) ComputeTangents(p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
GlbTextureRef TextureRef(const Json& material, const char* key, const char* scaleKey) {
|
||||||
|
GlbTextureRef ref;
|
||||||
|
if (const Json* info = Find(material, key)) {
|
||||||
|
ref.texture = static_cast<std::int32_t>(Integer(*info, "index", -1));
|
||||||
|
ref.texCoord = static_cast<std::uint32_t>(Integer(*info, "texCoord", 0));
|
||||||
|
if (scaleKey != nullptr) ref.scale = Number(*info, scaleKey, 1.0f);
|
||||||
|
}
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each index points at an element that exists, so callers can index
|
||||||
|
// without checking.
|
||||||
|
std::expected<void, std::string> CheckReferences(const GlbAsset& a) {
|
||||||
|
auto valid = [](std::int64_t index, std::size_t size) { return index == -1 || (index >= 0 && static_cast<std::size_t>(index) < size); };
|
||||||
|
for (std::size_t i = 0; i < a.nodes.size(); ++i) {
|
||||||
|
if (!valid(a.nodes[i].mesh, a.meshes.size())) return Error(std::format("node {} uses a mesh that doesn't exist", i));
|
||||||
|
if (!valid(a.nodes[i].skin, a.skins.size())) return Error(std::format("node {} uses a skin that doesn't exist", i));
|
||||||
|
if (!valid(a.nodes[i].camera, a.cameras.size())) return Error(std::format("node {} uses a camera that doesn't exist", i));
|
||||||
|
if (!valid(a.nodes[i].light, a.lights.size())) return Error(std::format("node {} uses a light that doesn't exist", i));
|
||||||
|
}
|
||||||
|
for (const GlbMesh& mesh : a.meshes) {
|
||||||
|
for (const GlbPrimitive& p : mesh.primitives) {
|
||||||
|
if (!valid(p.material, a.materials.size())) return Error(std::format("mesh '{}' uses a material that doesn't exist", mesh.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A vertex's joint indices pick from the joint list of the skin on
|
||||||
|
// the node that draws its mesh.
|
||||||
|
for (const GlbNode& node : a.nodes) {
|
||||||
|
if (node.mesh == -1 || node.skin == -1) continue;
|
||||||
|
const std::size_t jointCount = a.skins[static_cast<std::size_t>(node.skin)].joints.size();
|
||||||
|
for (const GlbPrimitive& p : a.meshes[static_cast<std::size_t>(node.mesh)].primitives) {
|
||||||
|
for (const auto& joints : p.joints) {
|
||||||
|
if (std::ranges::any_of(joints, [jointCount](std::uint16_t j) { return j >= jointCount; })) {
|
||||||
|
return Error(std::format("node '{}' skins mesh '{}' with joint indices beyond its skin's {} joints",
|
||||||
|
node.name, a.meshes[static_cast<std::size_t>(node.mesh)].name, jointCount));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const GlbSkin& skin : a.skins) {
|
||||||
|
if (!valid(skin.skeleton, a.nodes.size())) return Error(std::format("skin '{}' has a skeleton node that doesn't exist", skin.name));
|
||||||
|
for (std::uint32_t joint : skin.joints) {
|
||||||
|
if (joint >= a.nodes.size()) return Error(std::format("skin '{}' uses node {}, which doesn't exist", skin.name, joint));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (std::size_t i = 0; i < a.textures.size(); ++i) {
|
||||||
|
if (!valid(a.textures[i].image, a.images.size())) return Error(std::format("texture {} uses an image that doesn't exist", i));
|
||||||
|
if (!valid(a.textures[i].sampler, a.samplers.size())) return Error(std::format("texture {} uses a sampler that doesn't exist", i));
|
||||||
|
}
|
||||||
|
for (const GlbMaterial& m : a.materials) {
|
||||||
|
for (const GlbTextureRef* ref : {&m.baseColorTexture, &m.metallicRoughnessTexture, &m.normalTexture, &m.occlusionTexture, &m.emissiveTexture,
|
||||||
|
&m.transmissionTexture}) {
|
||||||
|
if (!valid(ref->texture, a.textures.size())) return Error(std::format("material '{}' uses a texture that doesn't exist", m.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::expected<GlbAsset, std::string> GlbAsset::Load(const fs::path& path) {
|
||||||
|
// A directory or a pipe opens too, with no size to read.
|
||||||
|
std::error_code error;
|
||||||
|
if (!fs::is_regular_file(path, error)) return Error(std::format("{} isn't a file", path.string()));
|
||||||
|
const std::uintmax_t size = fs::file_size(path, error);
|
||||||
|
if (error) return Error(std::format("can't read {}: {}", path.string(), error.message()));
|
||||||
|
std::ifstream file(path, std::ios::binary);
|
||||||
|
if (!file) return Error(std::format("can't open {}", path.string()));
|
||||||
|
std::vector<std::byte> bytes(static_cast<std::size_t>(size));
|
||||||
|
if (!file.read(reinterpret_cast<char*>(bytes.data()), static_cast<std::streamsize>(bytes.size()))) {
|
||||||
|
return Error(std::format("can't read {}", path.string()));
|
||||||
|
}
|
||||||
|
auto asset = Load(std::span<const std::byte>(bytes));
|
||||||
|
if (!asset) return std::unexpected(std::format("{} ({})", asset.error(), path.string()));
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::expected<GlbAsset, std::string> GlbAsset::Load(std::span<const std::byte> glb) {
|
||||||
|
auto u32 = [&](std::size_t offset) {
|
||||||
|
std::uint32_t value;
|
||||||
|
std::memcpy(&value, glb.data() + offset, sizeof(value));
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
if (glb.size() < 12) return Error("too short to be a GLB");
|
||||||
|
if (u32(0) != kMagic) return Error("bad magic, not a .glb");
|
||||||
|
if (u32(4) != 2) return Error(std::format("container version {}, only 2 is supported", u32(4)));
|
||||||
|
const std::size_t length = u32(8);
|
||||||
|
if (length > glb.size()) return Error(std::format("truncated: the header says {} bytes, there are {}", length, glb.size()));
|
||||||
|
|
||||||
|
// Chunks follow the header back to back. Unknown chunk types are skipped,
|
||||||
|
// as the spec asks.
|
||||||
|
std::span<const std::byte> jsonChunk;
|
||||||
|
std::span<const std::byte> binChunk;
|
||||||
|
for (std::size_t offset = 12; offset + 8 <= length;) {
|
||||||
|
const std::size_t chunkLength = u32(offset);
|
||||||
|
const std::uint32_t chunkType = u32(offset + 4);
|
||||||
|
// Not offset + 8 + chunkLength: that wraps round where size_t has 32 bits.
|
||||||
|
if (chunkLength > length - offset - 8) return Error("truncated chunk");
|
||||||
|
const std::span<const std::byte> chunk = glb.subspan(offset + 8, chunkLength);
|
||||||
|
if (chunkType == kChunkJson && jsonChunk.empty()) jsonChunk = chunk;
|
||||||
|
else if (chunkType == kChunkBin && binChunk.empty()) binChunk = chunk;
|
||||||
|
offset += 8 + chunkLength;
|
||||||
|
}
|
||||||
|
if (jsonChunk.empty()) return Error("no JSON chunk");
|
||||||
|
|
||||||
|
GlbAsset asset;
|
||||||
|
asset.json.assign(reinterpret_cast<const char*>(jsonChunk.data()), jsonChunk.size());
|
||||||
|
const Json root = Json::parse(asset.json, nullptr, false);
|
||||||
|
if (root.is_discarded() || !root.is_object()) return Error("the JSON chunk doesn't parse");
|
||||||
|
const Json* info = Find(root, "asset");
|
||||||
|
if (info == nullptr || !String(*info, "version").starts_with("2")) return Error("not glTF 2.x");
|
||||||
|
const Context ctx{root, binChunk};
|
||||||
|
|
||||||
|
// Nodes, then the parent links their children lists imply.
|
||||||
|
for (const Json& node : Array(root, "nodes")) {
|
||||||
|
GlbNode& n = asset.nodes.emplace_back();
|
||||||
|
n.name = String(node, "name");
|
||||||
|
for (const Json& child : Array(node, "children")) {
|
||||||
|
if (!child.is_number_unsigned()) return Error(std::format("node '{}' has a child that isn't a node index", n.name));
|
||||||
|
n.children.push_back(child.get<std::uint32_t>());
|
||||||
|
}
|
||||||
|
std::array<float, 3> t = {0, 0, 0};
|
||||||
|
std::array<float, 4> r = {0, 0, 0, 1};
|
||||||
|
std::array<float, 3> s = {1, 1, 1};
|
||||||
|
Numbers(node, "translation", t);
|
||||||
|
Numbers(node, "rotation", r);
|
||||||
|
Numbers(node, "scale", s);
|
||||||
|
n.translation = Vector<float, 3, 0>(t[0], t[1], t[2]);
|
||||||
|
n.rotation = Vector<float, 4, 0>(r[0], r[1], r[2], r[3]);
|
||||||
|
n.scale = Vector<float, 3, 0>(s[0], s[1], s[2]);
|
||||||
|
if (Array(node, "matrix").size() == 16) {
|
||||||
|
std::array<float, 16> m = {};
|
||||||
|
Numbers(node, "matrix", m);
|
||||||
|
n.hasMatrix = true;
|
||||||
|
n.matrix = MatrixRowMajor<float, 4, 4, 1>(m[0], m[1], m[2], m[3], m[4], m[5], m[6], m[7],
|
||||||
|
m[8], m[9], m[10], m[11], m[12], m[13], m[14], m[15]);
|
||||||
|
}
|
||||||
|
n.mesh = static_cast<std::int32_t>(Integer(node, "mesh", -1));
|
||||||
|
n.skin = static_cast<std::int32_t>(Integer(node, "skin", -1));
|
||||||
|
n.camera = static_cast<std::int32_t>(Integer(node, "camera", -1));
|
||||||
|
if (const Json* punctual = Extension(node, "KHR_lights_punctual")) {
|
||||||
|
n.light = static_cast<std::int32_t>(Integer(*punctual, "light", -1));
|
||||||
|
}
|
||||||
|
n.extras = Extras(node);
|
||||||
|
}
|
||||||
|
for (std::size_t i = 0; i < asset.nodes.size(); ++i) {
|
||||||
|
for (std::uint32_t child : asset.nodes[i].children) {
|
||||||
|
if (child >= asset.nodes.size()) return Error(std::format("node {} has child {}, which doesn't exist", i, child));
|
||||||
|
if (asset.nodes[child].parent != -1) return Error(std::format("node {} has two parents", child));
|
||||||
|
asset.nodes[child].parent = static_cast<std::int32_t>(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Every node reaches a root, or the children lists form a cycle. Each
|
||||||
|
// walk up stops at a node an earlier one found reaching a root, so every
|
||||||
|
// node is stepped through once, however deep the hierarchy.
|
||||||
|
enum class Seen : std::uint8_t { No, OnThisWalk, ReachesRoot };
|
||||||
|
std::vector<Seen> seen(asset.nodes.size(), Seen::No);
|
||||||
|
std::vector<std::size_t> walked;
|
||||||
|
for (std::size_t i = 0; i < asset.nodes.size(); ++i) {
|
||||||
|
walked.clear();
|
||||||
|
for (std::int32_t at = static_cast<std::int32_t>(i); at != -1 && seen[static_cast<std::size_t>(at)] != Seen::ReachesRoot;
|
||||||
|
at = asset.nodes[static_cast<std::size_t>(at)].parent) {
|
||||||
|
if (seen[static_cast<std::size_t>(at)] == Seen::OnThisWalk) return Error("the node hierarchy has a cycle");
|
||||||
|
seen[static_cast<std::size_t>(at)] = Seen::OnThisWalk;
|
||||||
|
walked.push_back(static_cast<std::size_t>(at));
|
||||||
|
}
|
||||||
|
for (std::size_t node : walked) seen[node] = Seen::ReachesRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Json& scenes = Array(root, "scenes");
|
||||||
|
const std::int64_t scene = Integer(root, "scene", 0);
|
||||||
|
if (scene >= 0 && static_cast<std::size_t>(scene) < scenes.size()) {
|
||||||
|
for (const Json& node : Array(scenes[static_cast<std::size_t>(scene)], "nodes")) {
|
||||||
|
if (!node.is_number_unsigned() || node.get<std::size_t>() >= asset.nodes.size()) {
|
||||||
|
return Error("the default scene names a node that doesn't exist");
|
||||||
|
}
|
||||||
|
asset.sceneRoots.push_back(node.get<std::uint32_t>());
|
||||||
|
}
|
||||||
|
asset.extras = Extras(scenes[static_cast<std::size_t>(scene)]);
|
||||||
|
} else {
|
||||||
|
for (std::size_t i = 0; i < asset.nodes.size(); ++i) {
|
||||||
|
if (asset.nodes[i].parent == -1) asset.sceneRoots.push_back(static_cast<std::uint32_t>(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const Json& meshes = Array(root, "meshes");
|
||||||
|
for (std::size_t m = 0; m < meshes.size(); ++m) {
|
||||||
|
GlbMesh& mesh = asset.meshes.emplace_back();
|
||||||
|
mesh.name = String(meshes[m], "name");
|
||||||
|
const Json& primitives = Array(meshes[m], "primitives");
|
||||||
|
for (std::size_t p = 0; p < primitives.size(); ++p) {
|
||||||
|
auto primitive = ReadPrimitive(ctx, primitives[p], std::format("mesh {} primitive {}", m, p));
|
||||||
|
if (!primitive) return std::unexpected(primitive.error());
|
||||||
|
mesh.primitives.push_back(std::move(*primitive));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const Json& skin : Array(root, "skins")) {
|
||||||
|
GlbSkin& s = asset.skins.emplace_back();
|
||||||
|
s.name = String(skin, "name");
|
||||||
|
for (const Json& joint : Array(skin, "joints")) {
|
||||||
|
if (!joint.is_number_unsigned()) return Error(std::format("skin '{}' has a joint that isn't a node index", s.name));
|
||||||
|
s.joints.push_back(joint.get<std::uint32_t>());
|
||||||
|
}
|
||||||
|
s.skeleton = static_cast<std::int32_t>(Integer(skin, "skeleton", -1));
|
||||||
|
if (Find(skin, "inverseBindMatrices") != nullptr) {
|
||||||
|
const auto ibm = ReadAccessor(ctx, Integer(skin, "inverseBindMatrices", -1));
|
||||||
|
if (!ibm) return std::unexpected(std::format("{} (inverseBindMatrices of skin '{}')", ibm.error(), s.name));
|
||||||
|
if (ibm->components != 16 || ibm->componentType != kFloat || ibm->count < s.joints.size()) {
|
||||||
|
return Error(std::format("inverseBindMatrices of skin '{}' aren't one float MAT4 per joint", s.name));
|
||||||
|
}
|
||||||
|
for (std::size_t j = 0; j < s.joints.size(); ++j) {
|
||||||
|
float m[16];
|
||||||
|
for (std::uint32_t c = 0; c < 16; ++c) m[c] = ibm->Float(j, c);
|
||||||
|
// glTF's column-major array as the rows: the row-vector form.
|
||||||
|
s.inverseBindMatrices.emplace_back(m[0], m[1], m[2], m[3], m[4], m[5], m[6], m[7],
|
||||||
|
m[8], m[9], m[10], m[11], m[12], m[13], m[14], m[15]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
s.inverseBindMatrices.assign(s.joints.size(), MatrixRowMajor<float, 4, 4, 1>::Identity());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const Json& material : Array(root, "materials")) {
|
||||||
|
GlbMaterial& m = asset.materials.emplace_back();
|
||||||
|
m.name = String(material, "name");
|
||||||
|
if (const Json* pbr = Find(material, "pbrMetallicRoughness")) {
|
||||||
|
Numbers(*pbr, "baseColorFactor", m.baseColorFactor);
|
||||||
|
m.metallicFactor = Number(*pbr, "metallicFactor", 1.0f);
|
||||||
|
m.roughnessFactor = Number(*pbr, "roughnessFactor", 1.0f);
|
||||||
|
m.baseColorTexture = TextureRef(*pbr, "baseColorTexture", nullptr);
|
||||||
|
m.metallicRoughnessTexture = TextureRef(*pbr, "metallicRoughnessTexture", nullptr);
|
||||||
|
}
|
||||||
|
m.normalTexture = TextureRef(material, "normalTexture", "scale");
|
||||||
|
m.occlusionTexture = TextureRef(material, "occlusionTexture", "strength");
|
||||||
|
m.emissiveTexture = TextureRef(material, "emissiveTexture", nullptr);
|
||||||
|
Numbers(material, "emissiveFactor", m.emissiveFactor);
|
||||||
|
const std::string alphaMode = String(material, "alphaMode");
|
||||||
|
m.alphaMode = alphaMode == "MASK" ? GlbAlphaMode::Mask : alphaMode == "BLEND" ? GlbAlphaMode::Blend : GlbAlphaMode::Opaque;
|
||||||
|
m.alphaCutoff = Number(material, "alphaCutoff", 0.5f);
|
||||||
|
m.doubleSided = Boolean(material, "doubleSided", false);
|
||||||
|
if (const Json* strength = Extension(material, "KHR_materials_emissive_strength")) {
|
||||||
|
m.emissiveStrength = Number(*strength, "emissiveStrength", 1.0f);
|
||||||
|
}
|
||||||
|
if (const Json* transmission = Extension(material, "KHR_materials_transmission")) {
|
||||||
|
m.transmissionFactor = Number(*transmission, "transmissionFactor", 0.0f);
|
||||||
|
m.transmissionTexture = TextureRef(*transmission, "transmissionTexture", nullptr);
|
||||||
|
}
|
||||||
|
if (const Json* ior = Extension(material, "KHR_materials_ior")) {
|
||||||
|
m.ior = Number(*ior, "ior", 1.5f);
|
||||||
|
}
|
||||||
|
m.extras = Extras(material);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const Json& camera : Array(root, "cameras")) {
|
||||||
|
GlbCamera& c = asset.cameras.emplace_back();
|
||||||
|
c.name = String(camera, "name");
|
||||||
|
c.orthographic = String(camera, "type") == "orthographic";
|
||||||
|
if (const Json* orthographic = Find(camera, "orthographic"); c.orthographic && orthographic != nullptr) {
|
||||||
|
c.xmag = Number(*orthographic, "xmag", 0.0f);
|
||||||
|
c.ymag = Number(*orthographic, "ymag", 0.0f);
|
||||||
|
c.znear = Number(*orthographic, "znear", 0.0f);
|
||||||
|
c.zfar = Number(*orthographic, "zfar", 0.0f);
|
||||||
|
} else if (const Json* perspective = Find(camera, "perspective"); !c.orthographic && perspective != nullptr) {
|
||||||
|
c.yfov = Number(*perspective, "yfov", 0.0f);
|
||||||
|
c.aspectRatio = Number(*perspective, "aspectRatio", 0.0f);
|
||||||
|
c.znear = Number(*perspective, "znear", 0.0f);
|
||||||
|
c.zfar = Number(*perspective, "zfar", 0.0f);
|
||||||
|
} else {
|
||||||
|
return Error(std::format("camera '{}' has no {} properties", c.name, c.orthographic ? "orthographic" : "perspective"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (const Json* punctual = Extension(root, "KHR_lights_punctual")) {
|
||||||
|
for (const Json& light : Array(*punctual, "lights")) {
|
||||||
|
GlbLight& l = asset.lights.emplace_back();
|
||||||
|
l.name = String(light, "name");
|
||||||
|
const std::string type = String(light, "type");
|
||||||
|
if (type == "directional") l.type = GlbLightType::Directional;
|
||||||
|
else if (type == "point") l.type = GlbLightType::Point;
|
||||||
|
else if (type == "spot") l.type = GlbLightType::Spot;
|
||||||
|
else return Error(std::format("light '{}' is of type '{}', which KHR_lights_punctual doesn't define", l.name, type));
|
||||||
|
Numbers(light, "color", l.color);
|
||||||
|
l.intensity = Number(light, "intensity", 1.0f);
|
||||||
|
l.range = Number(light, "range", 0.0f);
|
||||||
|
if (const Json* spot = Find(light, "spot")) {
|
||||||
|
l.innerConeAngle = Number(*spot, "innerConeAngle", l.innerConeAngle);
|
||||||
|
l.outerConeAngle = Number(*spot, "outerConeAngle", l.outerConeAngle);
|
||||||
|
}
|
||||||
|
l.extras = Extras(light);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const Json& texture : Array(root, "textures")) {
|
||||||
|
asset.textures.push_back({
|
||||||
|
.image = static_cast<std::int32_t>(Integer(texture, "source", -1)),
|
||||||
|
.sampler = static_cast<std::int32_t>(Integer(texture, "sampler", -1)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const Json& sampler : Array(root, "samplers")) {
|
||||||
|
asset.samplers.push_back({
|
||||||
|
.magFilter = static_cast<std::uint32_t>(Integer(sampler, "magFilter", 0)),
|
||||||
|
.minFilter = static_cast<std::uint32_t>(Integer(sampler, "minFilter", 0)),
|
||||||
|
.wrapS = static_cast<std::uint32_t>(Integer(sampler, "wrapS", 10497)),
|
||||||
|
.wrapT = static_cast<std::uint32_t>(Integer(sampler, "wrapT", 10497)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const Json& images = Array(root, "images");
|
||||||
|
for (std::size_t i = 0; i < images.size(); ++i) {
|
||||||
|
if (Find(images[i], "uri") != nullptr) {
|
||||||
|
return Error(std::format("image {} is an external file or data URI, which a GLB loader doesn't read", i));
|
||||||
|
}
|
||||||
|
const auto bytes = ViewBytes(ctx, Integer(images[i], "bufferView", -1), nullptr);
|
||||||
|
if (!bytes) return std::unexpected(std::format("{} (image {})", bytes.error(), i));
|
||||||
|
auto image = TextureAsset<GlbRGBA8>::LoadPNGFromMemory<std::uint8_t>(*bytes);
|
||||||
|
if (!image) return Error(std::format("image {} doesn't decode: {}", i, image.error()));
|
||||||
|
asset.images.push_back(std::move(*image));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auto valid = CheckReferences(asset); !valid) return std::unexpected(valid.error());
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
191
interfaces/Crafter.Asset-GLB.cppm
Normal file
191
interfaces/Crafter.Asset-GLB.cppm
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
//SPDX-License-Identifier: LGPL-3.0-only
|
||||||
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
export module Crafter.Asset:GLB;
|
||||||
|
import :Texture;
|
||||||
|
import std;
|
||||||
|
import Crafter.Math;
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
|
||||||
|
// glTF 2.0 binary (.glb) loading: the node hierarchy, every mesh's
|
||||||
|
// triangle primitives with their vertex streams, skins, materials, textures
|
||||||
|
// and decoded images, cameras, and the lights of KHR_lights_punctual. The JSON
|
||||||
|
// chunk stays available as text, for the extensions this loader doesn't
|
||||||
|
// interpret (VRMC_springBone, ...), and so do the `extras` of the scene,
|
||||||
|
// nodes, materials and lights (Blender writes custom properties there);
|
||||||
|
// parsing those is the caller's business, which keeps the JSON library out of
|
||||||
|
// this interface and out of everything that imports Crafter.Asset.
|
||||||
|
//
|
||||||
|
// Errors in the file come back as the error of the std::expected rather than
|
||||||
|
// through Compression::Fatal: a GLB is user content, and a bad one shouldn't
|
||||||
|
// take the program down.
|
||||||
|
export namespace Crafter {
|
||||||
|
struct GlbRGBA8 {
|
||||||
|
std::uint8_t r, g, b, a;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct GlbNode {
|
||||||
|
std::string name;
|
||||||
|
std::int32_t parent = -1;
|
||||||
|
std::vector<std::uint32_t> children;
|
||||||
|
// The local transform, as TRS unless the file gives a matrix. The
|
||||||
|
// matrix is the 4x4 row-vector form (translation in row 3), which is
|
||||||
|
// glTF's column-major array copied into the rows unchanged.
|
||||||
|
Vector<float, 3, 0> translation = Vector<float, 3, 0>(0, 0, 0);
|
||||||
|
Vector<float, 4, 0> rotation = Vector<float, 4, 0>(0, 0, 0, 1);
|
||||||
|
Vector<float, 3, 0> scale = Vector<float, 3, 0>(1, 1, 1);
|
||||||
|
bool hasMatrix = false;
|
||||||
|
MatrixRowMajor<float, 4, 4, 1> matrix = MatrixRowMajor<float, 4, 4, 1>::Identity();
|
||||||
|
std::int32_t mesh = -1;
|
||||||
|
std::int32_t skin = -1;
|
||||||
|
std::int32_t camera = -1;
|
||||||
|
// A light of KHR_lights_punctual: GlbAsset::lights.
|
||||||
|
std::int32_t light = -1;
|
||||||
|
// The node's extras as JSON text, or empty.
|
||||||
|
std::string extras;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A camera looks down its node's -Z, with +Y up.
|
||||||
|
struct GlbCamera {
|
||||||
|
std::string name;
|
||||||
|
bool orthographic = false;
|
||||||
|
// Perspective: the vertical field of view in radians, and the aspect
|
||||||
|
// ratio, or 0 when the file leaves it to the viewport.
|
||||||
|
float yfov = 0.0f;
|
||||||
|
float aspectRatio = 0.0f;
|
||||||
|
// Orthographic: half the view's width and height.
|
||||||
|
float xmag = 0.0f;
|
||||||
|
float ymag = 0.0f;
|
||||||
|
float znear = 0.0f;
|
||||||
|
// 0 for a perspective camera without a far plane.
|
||||||
|
float zfar = 0.0f;
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class GlbLightType : std::uint8_t {
|
||||||
|
Directional,
|
||||||
|
Point,
|
||||||
|
Spot,
|
||||||
|
};
|
||||||
|
|
||||||
|
// A KHR_lights_punctual light. It sits at its node's origin; a
|
||||||
|
// directional or spot light shines down the node's -Z.
|
||||||
|
struct GlbLight {
|
||||||
|
std::string name;
|
||||||
|
GlbLightType type = GlbLightType::Point;
|
||||||
|
std::array<float, 3> color = {1, 1, 1};
|
||||||
|
// Candela for a point or spot light, lux for a directional one.
|
||||||
|
float intensity = 1.0f;
|
||||||
|
// Where its light ends, or 0 when it goes on with distance's falloff.
|
||||||
|
float range = 0.0f;
|
||||||
|
// A spot light's cone, from its axis, in radians.
|
||||||
|
float innerConeAngle = 0.0f;
|
||||||
|
float outerConeAngle = std::numbers::pi_v<float> / 4.0f;
|
||||||
|
// The light's extras as JSON text, or empty.
|
||||||
|
std::string extras;
|
||||||
|
};
|
||||||
|
|
||||||
|
// One triangle list. Streams the primitive doesn't have stay empty;
|
||||||
|
// otherwise each has one entry per vertex.
|
||||||
|
struct GlbPrimitive {
|
||||||
|
std::vector<Vector<float, 3, 3>> positions;
|
||||||
|
std::vector<Vector<float, 3, 3>> normals;
|
||||||
|
// xyz plus the bitangent's handedness in w. Computed from the UVs when
|
||||||
|
// the file has none but has normals and TEXCOORD_0.
|
||||||
|
std::vector<Vector<float, 4, 4>> tangents;
|
||||||
|
std::vector<Vector<float, 2, 2>> uv0;
|
||||||
|
std::vector<std::array<std::uint16_t, 4>> joints;
|
||||||
|
// Normalized to sum to 1, which the file is meant to do and often
|
||||||
|
// only nearly does.
|
||||||
|
std::vector<std::array<float, 4>> weights;
|
||||||
|
// 0..n-1 when the primitive isn't indexed.
|
||||||
|
std::vector<std::uint32_t> indices;
|
||||||
|
std::int32_t material = -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct GlbMesh {
|
||||||
|
std::string name;
|
||||||
|
std::vector<GlbPrimitive> primitives;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct GlbSkin {
|
||||||
|
std::string name;
|
||||||
|
std::vector<std::uint32_t> joints;
|
||||||
|
// One per joint, in the 4x4 row-vector form like GlbNode::matrix;
|
||||||
|
// identity when the file leaves them out.
|
||||||
|
std::vector<MatrixRowMajor<float, 4, 4, 1>> inverseBindMatrices;
|
||||||
|
std::int32_t skeleton = -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class GlbAlphaMode : std::uint8_t {
|
||||||
|
Opaque,
|
||||||
|
Mask,
|
||||||
|
Blend,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct GlbTextureRef {
|
||||||
|
std::int32_t texture = -1;
|
||||||
|
std::uint32_t texCoord = 0;
|
||||||
|
// normalTexture's scale or occlusionTexture's strength.
|
||||||
|
float scale = 1.0f;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct GlbMaterial {
|
||||||
|
std::string name;
|
||||||
|
std::array<float, 4> baseColorFactor = {1, 1, 1, 1};
|
||||||
|
float metallicFactor = 1.0f;
|
||||||
|
float roughnessFactor = 1.0f;
|
||||||
|
std::array<float, 3> emissiveFactor = {0, 0, 0};
|
||||||
|
GlbTextureRef baseColorTexture;
|
||||||
|
GlbTextureRef metallicRoughnessTexture;
|
||||||
|
GlbTextureRef normalTexture;
|
||||||
|
GlbTextureRef occlusionTexture;
|
||||||
|
GlbTextureRef emissiveTexture;
|
||||||
|
GlbAlphaMode alphaMode = GlbAlphaMode::Opaque;
|
||||||
|
float alphaCutoff = 0.5f;
|
||||||
|
bool doubleSided = false;
|
||||||
|
// KHR_materials_emissive_strength: emissiveFactor's multiplier.
|
||||||
|
float emissiveStrength = 1.0f;
|
||||||
|
// KHR_materials_transmission: how much light passes through the
|
||||||
|
// surface, times the texture's red channel.
|
||||||
|
float transmissionFactor = 0.0f;
|
||||||
|
GlbTextureRef transmissionTexture;
|
||||||
|
// KHR_materials_ior.
|
||||||
|
float ior = 1.5f;
|
||||||
|
// The material's extras as JSON text, or empty.
|
||||||
|
std::string extras;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The OpenGL filter and wrap enums, as in the file; 0 when unspecified.
|
||||||
|
struct GlbSampler {
|
||||||
|
std::uint32_t magFilter = 0;
|
||||||
|
std::uint32_t minFilter = 0;
|
||||||
|
std::uint32_t wrapS = 10497;
|
||||||
|
std::uint32_t wrapT = 10497;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct GlbTexture {
|
||||||
|
std::int32_t image = -1;
|
||||||
|
std::int32_t sampler = -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct GlbAsset {
|
||||||
|
std::vector<GlbNode> nodes;
|
||||||
|
// The root nodes of the default scene.
|
||||||
|
std::vector<std::uint32_t> sceneRoots;
|
||||||
|
std::vector<GlbMesh> meshes;
|
||||||
|
std::vector<GlbSkin> skins;
|
||||||
|
std::vector<GlbMaterial> materials;
|
||||||
|
std::vector<GlbTexture> textures;
|
||||||
|
std::vector<GlbSampler> samplers;
|
||||||
|
std::vector<TextureAsset<GlbRGBA8>> images;
|
||||||
|
std::vector<GlbCamera> cameras;
|
||||||
|
std::vector<GlbLight> lights;
|
||||||
|
// The default scene's extras as JSON text, or empty.
|
||||||
|
std::string extras;
|
||||||
|
// The JSON chunk, verbatim.
|
||||||
|
std::string json;
|
||||||
|
|
||||||
|
static std::expected<GlbAsset, std::string> Load(const fs::path& path);
|
||||||
|
static std::expected<GlbAsset, std::string> Load(std::span<const std::byte> glb);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -143,16 +143,57 @@ export namespace Crafter {
|
||||||
file.read(reinterpret_cast<char*>(pixels), sizeX * sizeY * sizeof(T));
|
file.read(reinterpret_cast<char*>(pixels), sizeX * sizeY * sizeof(T));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decodes anything stb_image reads (PNG, JPEG, ...) to 8-bit RGBA, or
|
||||||
|
// to normalized floats when TT is a floating-point component type.
|
||||||
template <typename TT>
|
template <typename TT>
|
||||||
static TextureAsset<T> LoadPNG(fs::path path) {
|
static TextureAsset<T> LoadPNG(fs::path path) {
|
||||||
TextureAsset<T> tex;
|
|
||||||
|
|
||||||
std::filesystem::path abs = std::filesystem::absolute(path);
|
std::filesystem::path abs = std::filesystem::absolute(path);
|
||||||
|
|
||||||
int sizeX;
|
int sizeX;
|
||||||
int sizeY;
|
int sizeY;
|
||||||
|
|
||||||
unsigned char* data = stbi_load(abs.string().c_str(), &sizeX, &sizeY, nullptr, 4);
|
unsigned char* data = stbi_load(abs.string().c_str(), &sizeX, &sizeY, nullptr, 4);
|
||||||
|
if (data == nullptr) {
|
||||||
|
Compression::Fatal(std::format("LoadPNG: {}: {}", abs.string(), stbi_failure_reason()));
|
||||||
|
}
|
||||||
|
if (!FitsSize(sizeX, sizeY)) {
|
||||||
|
stbi_image_free(data);
|
||||||
|
Compression::Fatal(std::format("LoadPNG: {}: {}x{} is more than a texture's 65535 a side", abs.string(), sizeX, sizeY));
|
||||||
|
}
|
||||||
|
return FromRGBA8<TT>(data, sizeX, sizeY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadPNG from an image that is already in memory, e.g. one embedded
|
||||||
|
// in a GLB. An image that doesn't decode comes back as an error value
|
||||||
|
// rather than a Fatal: the caller is reading a larger asset and can
|
||||||
|
// say which part was bad.
|
||||||
|
template <typename TT>
|
||||||
|
static std::expected<TextureAsset<T>, std::string> LoadPNGFromMemory(std::span<const std::byte> encoded) {
|
||||||
|
int sizeX;
|
||||||
|
int sizeY;
|
||||||
|
|
||||||
|
unsigned char* data = stbi_load_from_memory(reinterpret_cast<const stbi_uc*>(encoded.data()),
|
||||||
|
static_cast<int>(encoded.size()), &sizeX, &sizeY, nullptr, 4);
|
||||||
|
if (data == nullptr) {
|
||||||
|
return std::unexpected(std::string(stbi_failure_reason()));
|
||||||
|
}
|
||||||
|
if (!FitsSize(sizeX, sizeY)) {
|
||||||
|
stbi_image_free(data);
|
||||||
|
return std::unexpected(std::format("{}x{} is more than a texture's 65535 a side", sizeX, sizeY));
|
||||||
|
}
|
||||||
|
return FromRGBA8<TT>(data, sizeX, sizeY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
// sizeX and sizeY hold 16 bits.
|
||||||
|
static bool FitsSize(int sizeX, int sizeY) {
|
||||||
|
return sizeX <= std::numeric_limits<std::uint16_t>::max() && sizeY <= std::numeric_limits<std::uint16_t>::max();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Converts stb's RGBA8 buffer, classifies its opacity and frees it.
|
||||||
|
template <typename TT>
|
||||||
|
static TextureAsset<T> FromRGBA8(unsigned char* data, int sizeX, int sizeY) {
|
||||||
|
TextureAsset<T> tex;
|
||||||
|
|
||||||
tex.sizeX = sizeX;
|
tex.sizeX = sizeX;
|
||||||
tex.sizeY = sizeY;
|
tex.sizeY = sizeY;
|
||||||
|
|
|
||||||
|
|
@ -5,3 +5,4 @@ export module Crafter.Asset;
|
||||||
export import :Compression;
|
export import :Compression;
|
||||||
export import :Mesh;
|
export import :Mesh;
|
||||||
export import :Texture;
|
export import :Texture;
|
||||||
|
export import :GLB;
|
||||||
25677
lib/json.hpp
Normal file
25677
lib/json.hpp
Normal file
File diff suppressed because it is too large
Load diff
14
project.cpp
14
project.cpp
|
|
@ -72,24 +72,32 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
||||||
}
|
}
|
||||||
for (const fs::path& p : libdeflateSources) cfg.cFiles.push_back(p);
|
for (const fs::path& p : libdeflateSources) cfg.cFiles.push_back(p);
|
||||||
|
|
||||||
std::array<fs::path, 4> ifaces = {
|
std::array<fs::path, 5> ifaces = {
|
||||||
"interfaces/Crafter.Asset",
|
"interfaces/Crafter.Asset",
|
||||||
"interfaces/Crafter.Asset-Compression",
|
"interfaces/Crafter.Asset-Compression",
|
||||||
"interfaces/Crafter.Asset-Texture",
|
"interfaces/Crafter.Asset-Texture",
|
||||||
"interfaces/Crafter.Asset-Mesh",
|
"interfaces/Crafter.Asset-Mesh",
|
||||||
|
"interfaces/Crafter.Asset-GLB",
|
||||||
};
|
};
|
||||||
if (cfg.type == ConfigurationType::Executable) {
|
if (cfg.type == ConfigurationType::Executable) {
|
||||||
std::array<fs::path, 2> impls = {
|
std::array<fs::path, 3> impls = {
|
||||||
"implementations/Crafter.Asset-Compression",
|
"implementations/Crafter.Asset-Compression",
|
||||||
|
"implementations/Crafter.Asset-GLB",
|
||||||
"implementations/main",
|
"implementations/main",
|
||||||
};
|
};
|
||||||
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
||||||
} else {
|
} else {
|
||||||
std::array<fs::path, 1> impls = {
|
std::array<fs::path, 2> impls = {
|
||||||
"implementations/Crafter.Asset-Compression",
|
"implementations/Crafter.Asset-Compression",
|
||||||
|
"implementations/Crafter.Asset-GLB",
|
||||||
};
|
};
|
||||||
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tests link this project's static library. wasm builds run no tests.
|
||||||
|
if (!isWasm && cfg.type == ConfigurationType::LibraryStatic) {
|
||||||
|
cfg.AddTest("GLB").Dependencies({ &cfg });
|
||||||
|
}
|
||||||
|
|
||||||
return cfg;
|
return cfg;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
552
tests/GLB/main.cpp
Normal file
552
tests/GLB/main.cpp
Normal file
|
|
@ -0,0 +1,552 @@
|
||||||
|
//SPDX-License-Identifier: LGPL-3.0-only
|
||||||
|
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||||
|
|
||||||
|
// GlbAsset::Load on a GLB built here in memory: the node hierarchy (TRS and
|
||||||
|
// matrix nodes), two primitives with two materials, an interleaved
|
||||||
|
// position/normal buffer view (strided accessors), normalized UV and weight
|
||||||
|
// accessors, generated indices, computed tangents, a skin's inverse-bind
|
||||||
|
// matrices, an embedded PNG and an extension kept in the raw JSON. A room as
|
||||||
|
// Blender exports one: a camera, KHR_lights_punctual lights, glowing and glass
|
||||||
|
// materials, and extras. Then the ways a file can be broken, which must come
|
||||||
|
// back as errors rather than crash: bad magic, truncation, a sparse accessor,
|
||||||
|
// an accessor past its view, a light that doesn't exist, a stride or count
|
||||||
|
// whose arithmetic wraps round to look in bounds, a skin's skeleton that
|
||||||
|
// doesn't exist, a chunk longer than the file, a path that isn't a file, an
|
||||||
|
// image wider than a texture's 16-bit size holds, and children that form a
|
||||||
|
// cycle. A chain 100,000 nodes deep loads in good time: the cycle check
|
||||||
|
// walked every node up to its root.
|
||||||
|
//
|
||||||
|
// With CRAFTER_ASSET_TEST_GLB pointing at a real skinned .glb (a rig exported
|
||||||
|
// from Blender, say), it also loads that and checks every joint's world
|
||||||
|
// matrix times its inverse-bind matrix is the identity in the rest pose,
|
||||||
|
// which is what a skinned mesh at rest relies on.
|
||||||
|
|
||||||
|
#include <cstdlib>
|
||||||
|
import Crafter.Asset;
|
||||||
|
import Crafter.Math;
|
||||||
|
import std;
|
||||||
|
|
||||||
|
using namespace Crafter;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
int failures = 0;
|
||||||
|
|
||||||
|
void Check(bool ok, std::string_view what) {
|
||||||
|
std::println("{} {}", ok ? "PASS" : "FAIL", what);
|
||||||
|
if (!ok) ++failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Near(float a, float b, float epsilon = 1e-4f) {
|
||||||
|
return std::abs(a - b) < epsilon;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A 2x2 RGBA PNG: red, green, blue, and white with alpha 0.
|
||||||
|
constexpr std::array<std::uint8_t, 76> kPng = {
|
||||||
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
||||||
|
0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02, 0x08, 0x06, 0x00, 0x00, 0x00, 0x72, 0xb6, 0x0d,
|
||||||
|
0x24, 0x00, 0x00, 0x00, 0x13, 0x49, 0x44, 0x41, 0x54, 0x78, 0xda, 0x63, 0xf8, 0xcf, 0xc0, 0xf0,
|
||||||
|
0x1f, 0x0c, 0x81, 0x34, 0x08, 0x30, 0x00, 0x00, 0x48, 0xc9, 0x08, 0xf8, 0xc5, 0x34, 0xfd, 0x05,
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Writer {
|
||||||
|
std::vector<std::byte> bytes;
|
||||||
|
|
||||||
|
template <typename T>
|
||||||
|
void Put(T value) {
|
||||||
|
const auto* p = reinterpret_cast<const std::byte*>(&value);
|
||||||
|
bytes.insert(bytes.end(), p, p + sizeof(T));
|
||||||
|
}
|
||||||
|
void Pad(std::size_t alignment, std::byte fill) {
|
||||||
|
while (bytes.size() % alignment != 0) bytes.push_back(fill);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// header + JSON chunk (space-padded) + BIN chunk (zero-padded), as the GLB
|
||||||
|
// container lays them out.
|
||||||
|
std::vector<std::byte> BuildGlb(std::string_view json, const std::vector<std::byte>& bin) {
|
||||||
|
Writer jsonChunk;
|
||||||
|
for (char c : json) jsonChunk.Put(c);
|
||||||
|
jsonChunk.Pad(4, std::byte{' '});
|
||||||
|
std::vector<std::byte> binPadded = bin;
|
||||||
|
while (binPadded.size() % 4 != 0) binPadded.push_back(std::byte{0});
|
||||||
|
|
||||||
|
Writer out;
|
||||||
|
out.Put<std::uint32_t>(0x46546C67); // "glTF"
|
||||||
|
out.Put<std::uint32_t>(2);
|
||||||
|
out.Put<std::uint32_t>(static_cast<std::uint32_t>(12 + 8 + jsonChunk.bytes.size() + 8 + binPadded.size()));
|
||||||
|
out.Put<std::uint32_t>(static_cast<std::uint32_t>(jsonChunk.bytes.size()));
|
||||||
|
out.Put<std::uint32_t>(0x4E4F534A); // "JSON"
|
||||||
|
out.bytes.insert(out.bytes.end(), jsonChunk.bytes.begin(), jsonChunk.bytes.end());
|
||||||
|
out.Put<std::uint32_t>(static_cast<std::uint32_t>(binPadded.size()));
|
||||||
|
out.Put<std::uint32_t>(0x004E4942); // "BIN\0"
|
||||||
|
out.bytes.insert(out.bytes.end(), binPadded.begin(), binPadded.end());
|
||||||
|
return out.bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::byte> FixtureBin() {
|
||||||
|
Writer w;
|
||||||
|
// bufferView 0, offset 0: interleaved position + normal, stride 24.
|
||||||
|
const std::array<std::array<float, 3>, 3> positions = {{{0, 0, 0}, {1, 0, 0}, {0, 1, 0}}};
|
||||||
|
for (const auto& p : positions) {
|
||||||
|
for (float f : p) w.Put(f);
|
||||||
|
for (float f : {0.0f, 0.0f, 1.0f}) w.Put(f);
|
||||||
|
}
|
||||||
|
// bufferView 1, offset 72: TEXCOORD_0 as normalized u16.
|
||||||
|
for (std::uint16_t u : {0, 0, 65535, 0, 0, 65535}) w.Put(u);
|
||||||
|
// bufferView 2, offset 84: u8 indices.
|
||||||
|
for (std::uint8_t i : {0, 1, 2}) w.Put(i);
|
||||||
|
w.Pad(4, std::byte{0});
|
||||||
|
// bufferView 3, offset 88: JOINTS_0 as u8.
|
||||||
|
for (int v = 0; v < 3; ++v) for (std::uint8_t j : {0, 1, 0, 0}) w.Put(j);
|
||||||
|
// bufferView 4, offset 100: WEIGHTS_0 as normalized u8 that don't sum to 255.
|
||||||
|
for (int v = 0; v < 3; ++v) for (std::uint8_t j : {128, 64, 0, 0}) w.Put(j);
|
||||||
|
// bufferView 5, offset 112: the second primitive's positions.
|
||||||
|
for (float f : {0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 1.0f, 0.0f, 1.0f, 1.0f}) w.Put(f);
|
||||||
|
// bufferView 6, offset 148: two inverse-bind matrices, column-major.
|
||||||
|
for (float f : {1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f}) w.Put(f);
|
||||||
|
for (float f : {1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, -1.0f, 0.0f, 0.0f, 1.0f}) w.Put(f);
|
||||||
|
// bufferView 7, offset 276: the PNG.
|
||||||
|
for (std::uint8_t b : kPng) w.Put(b);
|
||||||
|
return w.bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string FixtureJson(std::string_view accessor6Extra = "", std::uint32_t accessor7Count = 2) {
|
||||||
|
return std::format(R"({{
|
||||||
|
"asset": {{"version": "2.0"}},
|
||||||
|
"scene": 0,
|
||||||
|
"scenes": [{{"nodes": [0]}}],
|
||||||
|
"nodes": [
|
||||||
|
{{"name": "Root", "children": [1], "mesh": 0, "skin": 0,
|
||||||
|
"translation": [1, 2, 3], "rotation": [0, 0, 0.70710678, 0.70710678], "scale": [2, 2, 2]}},
|
||||||
|
{{"name": "Child", "matrix": [1,0,0,0, 0,1,0,0, 0,0,1,0, 5,6,7,1]}}
|
||||||
|
],
|
||||||
|
"meshes": [{{"name": "Mesh", "primitives": [
|
||||||
|
{{"attributes": {{"POSITION": 0, "NORMAL": 1, "TEXCOORD_0": 2, "JOINTS_0": 4, "WEIGHTS_0": 5}}, "indices": 3, "material": 0}},
|
||||||
|
{{"attributes": {{"POSITION": 6}}, "material": 1}}
|
||||||
|
]}}],
|
||||||
|
"skins": [{{"name": "Skin", "joints": [0, 1], "inverseBindMatrices": 7}}],
|
||||||
|
"materials": [
|
||||||
|
{{"name": "Mat0", "pbrMetallicRoughness": {{"baseColorFactor": [0.5, 0.25, 1, 1], "metallicFactor": 0.1,
|
||||||
|
"roughnessFactor": 0.7, "baseColorTexture": {{"index": 0}}}},
|
||||||
|
"normalTexture": {{"index": 0, "scale": 0.5}}, "alphaMode": "MASK", "alphaCutoff": 0.3, "doubleSided": true}},
|
||||||
|
{{"name": "Mat1"}}
|
||||||
|
],
|
||||||
|
"textures": [{{"source": 0, "sampler": 0}}],
|
||||||
|
"samplers": [{{"magFilter": 9728, "minFilter": 9729}}],
|
||||||
|
"images": [{{"bufferView": 7, "mimeType": "image/png"}}],
|
||||||
|
"accessors": [
|
||||||
|
{{"bufferView": 0, "byteOffset": 0, "componentType": 5126, "count": 3, "type": "VEC3"}},
|
||||||
|
{{"bufferView": 0, "byteOffset": 12, "componentType": 5126, "count": 3, "type": "VEC3"}},
|
||||||
|
{{"bufferView": 1, "componentType": 5123, "normalized": true, "count": 3, "type": "VEC2"}},
|
||||||
|
{{"bufferView": 2, "componentType": 5121, "count": 3, "type": "SCALAR"}},
|
||||||
|
{{"bufferView": 3, "componentType": 5121, "count": 3, "type": "VEC4"}},
|
||||||
|
{{"bufferView": 4, "componentType": 5121, "normalized": true, "count": 3, "type": "VEC4"}},
|
||||||
|
{{"bufferView": 5, "componentType": 5126, "count": 3, "type": "VEC3"{}}},
|
||||||
|
{{"bufferView": 6, "componentType": 5126, "count": {}, "type": "MAT4"}}
|
||||||
|
],
|
||||||
|
"bufferViews": [
|
||||||
|
{{"buffer": 0, "byteOffset": 0, "byteLength": 72, "byteStride": 24}},
|
||||||
|
{{"buffer": 0, "byteOffset": 72, "byteLength": 12}},
|
||||||
|
{{"buffer": 0, "byteOffset": 84, "byteLength": 3}},
|
||||||
|
{{"buffer": 0, "byteOffset": 88, "byteLength": 12}},
|
||||||
|
{{"buffer": 0, "byteOffset": 100, "byteLength": 12}},
|
||||||
|
{{"buffer": 0, "byteOffset": 112, "byteLength": 36}},
|
||||||
|
{{"buffer": 0, "byteOffset": 148, "byteLength": 128}},
|
||||||
|
{{"buffer": 0, "byteOffset": 276, "byteLength": 76}}
|
||||||
|
],
|
||||||
|
"buffers": [{{"byteLength": 352}}],
|
||||||
|
"extensionsUsed": ["VRMC_springBone"],
|
||||||
|
"extensions": {{"VRMC_springBone": {{"specVersion": "1.0"}}}}
|
||||||
|
}})", accessor6Extra, accessor7Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool NearMatrix(const MatrixRowMajor<float, 4, 4, 1>& m, std::array<float, 16> expected) {
|
||||||
|
const std::array<float, 16> got = m.Store();
|
||||||
|
for (std::size_t i = 0; i < 16; ++i) {
|
||||||
|
if (!Near(got[i], expected[i])) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestFixture() {
|
||||||
|
const std::vector<std::byte> glb = BuildGlb(FixtureJson(), FixtureBin());
|
||||||
|
const std::expected<GlbAsset, std::string> loaded = GlbAsset::Load(glb);
|
||||||
|
Check(loaded.has_value(), loaded.has_value() ? "the fixture loads" : std::format("the fixture loads: {}", loaded.error()));
|
||||||
|
if (!loaded) return;
|
||||||
|
const GlbAsset& a = *loaded;
|
||||||
|
|
||||||
|
// Nodes and scene.
|
||||||
|
Check(a.nodes.size() == 2 && a.nodes[0].name == "Root" && a.nodes[1].name == "Child", "two named nodes");
|
||||||
|
Check(a.nodes[0].parent == -1 && a.nodes[1].parent == 0 && a.nodes[0].children == std::vector<std::uint32_t>{1},
|
||||||
|
"parent and children links");
|
||||||
|
Check(a.sceneRoots == std::vector<std::uint32_t>{0}, "the default scene's roots");
|
||||||
|
const GlbNode& root = a.nodes[0];
|
||||||
|
Check(!root.hasMatrix && Near(root.translation.x, 1) && Near(root.translation.y, 2) && Near(root.translation.z, 3)
|
||||||
|
&& Near(root.rotation.z, 0.70710678f) && Near(root.rotation.w, 0.70710678f) && Near(root.scale.y, 2),
|
||||||
|
"TRS node");
|
||||||
|
Check(root.mesh == 0 && root.skin == 0 && a.nodes[1].mesh == -1, "mesh and skin references");
|
||||||
|
Check(a.nodes[1].hasMatrix && NearMatrix(a.nodes[1].matrix, {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 5, 6, 7, 1}),
|
||||||
|
"matrix node, translation in row 3");
|
||||||
|
|
||||||
|
// Primitives.
|
||||||
|
Check(a.meshes.size() == 1 && a.meshes[0].primitives.size() == 2, "one mesh with two primitives");
|
||||||
|
if (a.meshes.size() != 1 || a.meshes[0].primitives.size() != 2) return;
|
||||||
|
const GlbPrimitive& p0 = a.meshes[0].primitives[0];
|
||||||
|
const GlbPrimitive& p1 = a.meshes[0].primitives[1];
|
||||||
|
Check(p0.positions.size() == 3 && Near(p0.positions[1].x, 1) && Near(p0.positions[2].y, 1),
|
||||||
|
"positions read through a strided view");
|
||||||
|
Check(p0.normals.size() == 3 && Near(p0.normals[2].z, 1) && Near(p0.normals[0].x, 0), "normals read at their byte offset");
|
||||||
|
Check(p0.uv0.size() == 3 && Near(p0.uv0[1].x, 1) && Near(p0.uv0[2].y, 1) && Near(p0.uv0[1].y, 0),
|
||||||
|
"normalized u16 UVs");
|
||||||
|
Check(p0.indices == std::vector<std::uint32_t>{0, 1, 2}, "u8 indices widened");
|
||||||
|
Check(p0.joints.size() == 3 && p0.joints[0] == std::array<std::uint16_t, 4>{0, 1, 0, 0}, "u8 joints");
|
||||||
|
Check(p0.weights.size() == 3 && Near(p0.weights[0][0], 2.0f / 3.0f) && Near(p0.weights[0][1], 1.0f / 3.0f),
|
||||||
|
"normalized u8 weights, renormalized to sum 1");
|
||||||
|
// glTF's v runs down the image, so the bitangent (normal map +Y, "up")
|
||||||
|
// points along -v: here -Y, which is cross(normal, tangent) * -1.
|
||||||
|
Check(p0.tangents.size() == 3 && Near(p0.tangents[0].x, 1) && Near(p0.tangents[0].y, 0)
|
||||||
|
&& Near(p0.tangents[0].z, 0) && Near(p0.tangents[0].w, -1),
|
||||||
|
"tangents computed from the UVs, with glTF's bitangent sign");
|
||||||
|
Check(p0.material == 0 && p1.material == 1, "primitive materials");
|
||||||
|
Check(p1.positions.size() == 3 && Near(p1.positions[2].z, 1) && p1.indices == std::vector<std::uint32_t>{0, 1, 2},
|
||||||
|
"a primitive without indices gets 0..n-1");
|
||||||
|
Check(p1.normals.empty() && p1.tangents.empty() && p1.joints.empty(), "absent streams stay empty");
|
||||||
|
|
||||||
|
// Skin.
|
||||||
|
Check(a.skins.size() == 1 && a.skins[0].joints == std::vector<std::uint32_t>{0, 1}
|
||||||
|
&& a.skins[0].inverseBindMatrices.size() == 2
|
||||||
|
&& NearMatrix(a.skins[0].inverseBindMatrices[1], {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -1, 0, 0, 1}),
|
||||||
|
"skin joints and inverse-bind matrices");
|
||||||
|
|
||||||
|
// Materials, textures, images.
|
||||||
|
Check(a.materials.size() == 2, "two materials");
|
||||||
|
if (a.materials.size() == 2) {
|
||||||
|
const GlbMaterial& m0 = a.materials[0];
|
||||||
|
Check(Near(m0.baseColorFactor[1], 0.25f) && Near(m0.metallicFactor, 0.1f) && Near(m0.roughnessFactor, 0.7f)
|
||||||
|
&& m0.baseColorTexture.texture == 0 && m0.normalTexture.texture == 0 && Near(m0.normalTexture.scale, 0.5f)
|
||||||
|
&& m0.alphaMode == GlbAlphaMode::Mask && Near(m0.alphaCutoff, 0.3f) && m0.doubleSided,
|
||||||
|
"material factors, textures and alpha mode");
|
||||||
|
const GlbMaterial& m1 = a.materials[1];
|
||||||
|
Check(Near(m1.metallicFactor, 1) && Near(m1.roughnessFactor, 1) && m1.baseColorTexture.texture == -1
|
||||||
|
&& m1.alphaMode == GlbAlphaMode::Opaque && !m1.doubleSided,
|
||||||
|
"material defaults");
|
||||||
|
}
|
||||||
|
Check(a.textures.size() == 1 && a.textures[0].image == 0 && a.textures[0].sampler == 0, "texture source and sampler");
|
||||||
|
Check(a.samplers.size() == 1 && a.samplers[0].magFilter == 9728 && a.samplers[0].minFilter == 9729, "sampler filters");
|
||||||
|
Check(a.images.size() == 1 && a.images[0].sizeX == 2 && a.images[0].sizeY == 2 && a.images[0].pixels.size() == 4,
|
||||||
|
"the embedded PNG decodes");
|
||||||
|
if (a.images.size() == 1 && a.images[0].pixels.size() == 4) {
|
||||||
|
const GlbRGBA8 first = a.images[0].pixels[0];
|
||||||
|
Check(first.r == 255 && first.g == 0 && first.b == 0 && first.a == 255 && a.images[0].pixels[3].a == 0
|
||||||
|
&& a.images[0].opaque == OpaqueType::SemiOpaque,
|
||||||
|
"image pixels and opacity");
|
||||||
|
}
|
||||||
|
Check(a.json.find("VRMC_springBone") != std::string::npos, "the raw JSON keeps extensions");
|
||||||
|
}
|
||||||
|
|
||||||
|
// What a room exported from Blender carries beyond meshes: a camera,
|
||||||
|
// KHR_lights_punctual lights, glowing and glass materials
|
||||||
|
// (KHR_materials_emissive_strength, _transmission, _ior), and the custom
|
||||||
|
// properties Blender writes as `extras` on the scene, nodes, materials and
|
||||||
|
// lights. The image is the fixture's PNG, at the same place in the buffer.
|
||||||
|
std::string RoomJson(std::string_view candleLight = "0") {
|
||||||
|
return std::format(R"({{
|
||||||
|
"asset": {{"version": "2.0"}},
|
||||||
|
"scene": 0,
|
||||||
|
"scenes": [{{"nodes": [0, 1, 2, 3, 4], "extras": {{"exposure": 1.5}}}}],
|
||||||
|
"nodes": [
|
||||||
|
{{"name": "Camera", "camera": 0, "translation": [0, 1.2, 1.5]}},
|
||||||
|
{{"name": "Candle", "extensions": {{"KHR_lights_punctual": {{"light": {}}}}}, "extras": {{"flicker": 0.3}}}},
|
||||||
|
{{"name": "Moon", "extensions": {{"KHR_lights_punctual": {{"light": 1}}}}}},
|
||||||
|
{{"name": "Sun", "extensions": {{"KHR_lights_punctual": {{"light": 2}}}}}},
|
||||||
|
{{"name": "Seat", "extras": {{"seat": true, "height": 1.6}}}}
|
||||||
|
],
|
||||||
|
"cameras": [{{"name": "Webcam", "type": "perspective",
|
||||||
|
"perspective": {{"yfov": 0.75, "aspectRatio": 1.7777778, "znear": 0.05, "zfar": 100}}}}],
|
||||||
|
"extensionsUsed": ["KHR_lights_punctual", "KHR_materials_emissive_strength", "KHR_materials_transmission", "KHR_materials_ior"],
|
||||||
|
"extensions": {{"KHR_lights_punctual": {{"lights": [
|
||||||
|
{{"name": "CandleLight", "type": "point", "color": [1, 0.6, 0.3], "intensity": 5, "range": 4, "extras": {{"shadows": false}}}},
|
||||||
|
{{"name": "MoonLight", "type": "spot", "intensity": 20, "spot": {{"innerConeAngle": 0.2, "outerConeAngle": 0.5}}}},
|
||||||
|
{{"name": "SunLight", "type": "directional", "color": [0.5, 0.6, 1], "intensity": 0.8}}
|
||||||
|
]}}}},
|
||||||
|
"materials": [
|
||||||
|
{{"name": "Flame", "emissiveFactor": [1, 0.5, 0.1],
|
||||||
|
"extensions": {{"KHR_materials_emissive_strength": {{"emissiveStrength": 8}}}}, "extras": {{"effect": "flicker"}}}},
|
||||||
|
{{"name": "StainedGlass", "pbrMetallicRoughness": {{"baseColorTexture": {{"index": 0}}}},
|
||||||
|
"extensions": {{"KHR_materials_transmission": {{"transmissionFactor": 0.9, "transmissionTexture": {{"index": 0}}}},
|
||||||
|
"KHR_materials_ior": {{"ior": 1.45}}}}}},
|
||||||
|
{{"name": "Plain"}}
|
||||||
|
],
|
||||||
|
"textures": [{{"source": 0}}],
|
||||||
|
"images": [{{"bufferView": 0, "mimeType": "image/png"}}],
|
||||||
|
"bufferViews": [{{"buffer": 0, "byteOffset": 276, "byteLength": 76}}],
|
||||||
|
"buffers": [{{"byteLength": 352}}]
|
||||||
|
}})", candleLight);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestRoom() {
|
||||||
|
const std::expected<GlbAsset, std::string> loaded = GlbAsset::Load(BuildGlb(RoomJson(), FixtureBin()));
|
||||||
|
Check(loaded.has_value(), loaded.has_value() ? "the room fixture loads" : std::format("the room fixture loads: {}", loaded.error()));
|
||||||
|
if (!loaded) return;
|
||||||
|
const GlbAsset& a = *loaded;
|
||||||
|
|
||||||
|
Check(a.cameras.size() == 1 && a.nodes[0].camera == 0 && a.nodes[1].camera == -1, "a node's camera");
|
||||||
|
if (a.cameras.size() == 1) {
|
||||||
|
const GlbCamera& c = a.cameras[0];
|
||||||
|
Check(c.name == "Webcam" && !c.orthographic && Near(c.yfov, 0.75f) && Near(c.aspectRatio, 1.7777778f)
|
||||||
|
&& Near(c.znear, 0.05f) && Near(c.zfar, 100),
|
||||||
|
"a perspective camera's field of view, aspect and clip planes");
|
||||||
|
}
|
||||||
|
|
||||||
|
Check(a.lights.size() == 3 && a.nodes[1].light == 0 && a.nodes[2].light == 1 && a.nodes[3].light == 2 && a.nodes[0].light == -1,
|
||||||
|
"nodes' KHR_lights_punctual lights");
|
||||||
|
if (a.lights.size() == 3) {
|
||||||
|
const GlbLight& point = a.lights[0];
|
||||||
|
Check(point.name == "CandleLight" && point.type == GlbLightType::Point && Near(point.color[1], 0.6f)
|
||||||
|
&& Near(point.intensity, 5) && Near(point.range, 4),
|
||||||
|
"a point light's colour, intensity and range");
|
||||||
|
const GlbLight& spot = a.lights[1];
|
||||||
|
Check(spot.type == GlbLightType::Spot && Near(spot.innerConeAngle, 0.2f) && Near(spot.outerConeAngle, 0.5f)
|
||||||
|
&& Near(spot.color[0], 1) && Near(spot.range, 0),
|
||||||
|
"a spot light's cone, with white and no range by default");
|
||||||
|
const GlbLight& sun = a.lights[2];
|
||||||
|
Check(sun.type == GlbLightType::Directional && Near(sun.intensity, 0.8f) && Near(sun.outerConeAngle, std::numbers::pi_v<float> / 4),
|
||||||
|
"a directional light, the cone left at the extension's default");
|
||||||
|
Check(point.extras == R"({"shadows":false})" && spot.extras.empty(), "a light's extras, as JSON text");
|
||||||
|
}
|
||||||
|
|
||||||
|
Check(a.materials.size() == 3, "three materials");
|
||||||
|
if (a.materials.size() == 3) {
|
||||||
|
const GlbMaterial& flame = a.materials[0];
|
||||||
|
Check(Near(flame.emissiveStrength, 8) && Near(flame.emissiveFactor[1], 0.5f) && Near(flame.transmissionFactor, 0),
|
||||||
|
"KHR_materials_emissive_strength");
|
||||||
|
const GlbMaterial& glass = a.materials[1];
|
||||||
|
Check(Near(glass.transmissionFactor, 0.9f) && glass.transmissionTexture.texture == 0 && Near(glass.ior, 1.45f),
|
||||||
|
"KHR_materials_transmission with its texture, and KHR_materials_ior");
|
||||||
|
const GlbMaterial& plain = a.materials[2];
|
||||||
|
Check(Near(plain.emissiveStrength, 1) && Near(plain.transmissionFactor, 0) && plain.transmissionTexture.texture == -1
|
||||||
|
&& Near(plain.ior, 1.5f) && plain.extras.empty(),
|
||||||
|
"the extensions' defaults where a material has none");
|
||||||
|
Check(flame.extras == R"({"effect":"flicker"})", "a material's extras, as JSON text");
|
||||||
|
}
|
||||||
|
|
||||||
|
Check(a.nodes[1].extras == R"({"flicker":0.3})" && a.nodes[4].extras == R"({"height":1.6,"seat":true})" && a.nodes[0].extras.empty(),
|
||||||
|
"nodes' extras, as JSON text");
|
||||||
|
Check(a.extras == R"({"exposure":1.5})", "the default scene's extras, as JSON text");
|
||||||
|
|
||||||
|
// A node may only name a light the extension defines.
|
||||||
|
const auto missingLight = GlbAsset::Load(BuildGlb(RoomJson("7"), FixtureBin()));
|
||||||
|
Check(!missingLight && missingLight.error().find("light") != std::string::npos, "a node naming a light that doesn't exist is an error");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A grey PNG `width` by 1, its row stored without compression: big-endian
|
||||||
|
// chunks with their CRCs, and zlib's stored blocks with its Adler-32.
|
||||||
|
std::vector<std::byte> WidePng(std::uint32_t width) {
|
||||||
|
std::vector<std::uint8_t> row(1 + width, 128);
|
||||||
|
row[0] = 0; // no filter
|
||||||
|
std::vector<std::uint8_t> zlib = {0x78, 0x01};
|
||||||
|
for (std::size_t at = 0; at < row.size();) {
|
||||||
|
const std::size_t n = std::min<std::size_t>(65535, row.size() - at);
|
||||||
|
zlib.push_back(at + n == row.size() ? 1 : 0);
|
||||||
|
for (std::uint16_t v : {static_cast<std::uint16_t>(n), static_cast<std::uint16_t>(~n)}) {
|
||||||
|
zlib.push_back(static_cast<std::uint8_t>(v));
|
||||||
|
zlib.push_back(static_cast<std::uint8_t>(v >> 8));
|
||||||
|
}
|
||||||
|
zlib.insert(zlib.end(), row.begin() + static_cast<std::ptrdiff_t>(at), row.begin() + static_cast<std::ptrdiff_t>(at + n));
|
||||||
|
at += n;
|
||||||
|
}
|
||||||
|
std::uint32_t a = 1, b = 0;
|
||||||
|
for (std::uint8_t c : row) {
|
||||||
|
a = (a + c) % 65521;
|
||||||
|
b = (b + a) % 65521;
|
||||||
|
}
|
||||||
|
std::vector<std::uint8_t> png = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'};
|
||||||
|
auto be32 = [](std::vector<std::uint8_t>& out, std::uint32_t v) {
|
||||||
|
for (int shift = 24; shift >= 0; shift -= 8) out.push_back(static_cast<std::uint8_t>(v >> shift));
|
||||||
|
};
|
||||||
|
be32(zlib, (b << 16) | a);
|
||||||
|
auto chunk = [&](const char* type, const std::vector<std::uint8_t>& data) {
|
||||||
|
be32(png, static_cast<std::uint32_t>(data.size()));
|
||||||
|
std::vector<std::uint8_t> typed(type, type + 4);
|
||||||
|
typed.insert(typed.end(), data.begin(), data.end());
|
||||||
|
std::uint32_t crc = 0xFFFFFFFF;
|
||||||
|
for (std::uint8_t byte : typed) {
|
||||||
|
crc ^= byte;
|
||||||
|
for (int k = 0; k < 8; ++k) crc = (crc >> 1) ^ (0xEDB88320 & (0 - (crc & 1)));
|
||||||
|
}
|
||||||
|
png.insert(png.end(), typed.begin(), typed.end());
|
||||||
|
be32(png, ~crc);
|
||||||
|
};
|
||||||
|
std::vector<std::uint8_t> header;
|
||||||
|
be32(header, width);
|
||||||
|
be32(header, 1);
|
||||||
|
header.insert(header.end(), {8, 0, 0, 0, 0}); // 8-bit grey
|
||||||
|
chunk("IHDR", header);
|
||||||
|
chunk("IDAT", zlib);
|
||||||
|
chunk("IEND", {});
|
||||||
|
std::vector<std::byte> out(png.size());
|
||||||
|
std::memcpy(out.data(), png.data(), png.size());
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestBrokenFiles() {
|
||||||
|
std::vector<std::byte> glb = BuildGlb(FixtureJson(), FixtureBin());
|
||||||
|
|
||||||
|
std::vector<std::byte> badMagic = glb;
|
||||||
|
badMagic[0] = std::byte{'X'};
|
||||||
|
const auto magic = GlbAsset::Load(badMagic);
|
||||||
|
Check(!magic && magic.error().find("magic") != std::string::npos, "bad magic is an error");
|
||||||
|
|
||||||
|
const std::vector<std::byte> truncated(glb.begin(), glb.begin() + static_cast<std::ptrdiff_t>(glb.size() / 2));
|
||||||
|
Check(!GlbAsset::Load(truncated), "a truncated file is an error");
|
||||||
|
|
||||||
|
const auto sparse = GlbAsset::Load(BuildGlb(
|
||||||
|
FixtureJson(R"(, "sparse": {"count": 1, "indices": {"bufferView": 2, "componentType": 5121}, "values": {"bufferView": 5}})"),
|
||||||
|
FixtureBin()));
|
||||||
|
Check(!sparse && sparse.error().find("sparse") != std::string::npos, "a sparse accessor is an error");
|
||||||
|
|
||||||
|
const auto outside = GlbAsset::Load(BuildGlb(FixtureJson("", 3), FixtureBin()));
|
||||||
|
Check(!outside && outside.error().find("bufferView") != std::string::npos, "an accessor past its buffer view is an error");
|
||||||
|
|
||||||
|
Check(!GlbAsset::Load(std::filesystem::path("/nonexistent/file.glb")), "a missing file is an error");
|
||||||
|
|
||||||
|
// The fixture's JSON with each `from` replaced by its `to`.
|
||||||
|
auto with = [](std::initializer_list<std::pair<std::string_view, std::string_view>> changes) {
|
||||||
|
std::string json = FixtureJson();
|
||||||
|
for (const auto& [from, to] : changes) json.replace(json.find(from), from.size(), to);
|
||||||
|
return BuildGlb(json, FixtureBin());
|
||||||
|
};
|
||||||
|
// Accessor 0 reads the interleaved view: 3 elements, 24 bytes apart. An
|
||||||
|
// offset and a negative stride that wrap round to land in bounds.
|
||||||
|
const auto wrappedStride = GlbAsset::Load(with({{R"("byteStride": 24)", R"("byteStride": -4096)"},
|
||||||
|
{R"({"bufferView": 0, "byteOffset": 0,)", R"({"bufferView": 0, "byteOffset": 8192,)"}}));
|
||||||
|
Check(!wrappedStride, "a negative byteStride is an error, however the offsets add up");
|
||||||
|
// A count whose elements' 24-byte strides wrap round to nothing.
|
||||||
|
const auto wrappedCount = GlbAsset::Load(with({{R"({"bufferView": 0, "byteOffset": 0, "componentType": 5126, "count": 3,)",
|
||||||
|
R"({"bufferView": 0, "byteOffset": 0, "componentType": 5126, "count": 2305843009213693953,)"}}));
|
||||||
|
Check(!wrappedCount, "a count too large for its view is an error, however its size wraps");
|
||||||
|
const auto skeleton = GlbAsset::Load(with({{R"("joints": [0, 1],)", R"("joints": [0, 1], "skeleton": 99,)"}}));
|
||||||
|
Check(!skeleton && skeleton.error().find("skeleton") != std::string::npos, "a skin's skeleton that doesn't exist is an error");
|
||||||
|
Check(!GlbAsset::Load(std::filesystem::temp_directory_path()), "a directory is an error");
|
||||||
|
|
||||||
|
// The first chunk's length, past the end of the file; on a 32-bit build
|
||||||
|
// its sum with the offset wraps round to a small number.
|
||||||
|
std::vector<std::byte> longChunk = glb;
|
||||||
|
const std::uint32_t huge = 0xFFFFFFF0;
|
||||||
|
std::memcpy(longChunk.data() + 12, &huge, sizeof(huge));
|
||||||
|
Check(!GlbAsset::Load(longChunk), "a chunk longer than the file is an error");
|
||||||
|
|
||||||
|
// Nodes alone: a cycle, and a long chain.
|
||||||
|
auto nodesOnly = [](std::string_view nodes) {
|
||||||
|
return BuildGlb(std::format(R"({{"asset": {{"version": "2.0"}}, "nodes": [{}]}})", nodes), {});
|
||||||
|
};
|
||||||
|
const auto cycle = GlbAsset::Load(nodesOnly(R"({"children": [1]}, {"children": [2]}, {"children": [0]})"));
|
||||||
|
Check(!cycle && cycle.error().find("cycle") != std::string::npos, "children that form a cycle are an error");
|
||||||
|
constexpr std::size_t kChain = 100000;
|
||||||
|
std::string chain;
|
||||||
|
for (std::size_t i = 0; i + 1 < kChain; ++i) chain += std::format(R"({{"children": [{}]}},)", i + 1);
|
||||||
|
chain += "{}";
|
||||||
|
const auto started = std::chrono::steady_clock::now();
|
||||||
|
const auto deep = GlbAsset::Load(nodesOnly(chain));
|
||||||
|
const auto took = std::chrono::steady_clock::now() - started;
|
||||||
|
Check(deep && deep->nodes.size() == kChain && deep->nodes.back().parent == static_cast<std::int32_t>(kChain - 2),
|
||||||
|
deep ? "a chain 100,000 nodes deep loads, each its parent's child" : std::format("a chain 100,000 nodes deep loads: {}", deep.error()));
|
||||||
|
Check(took < std::chrono::seconds(2), std::format("in good time ({} ms)", std::chrono::duration_cast<std::chrono::milliseconds>(took).count()));
|
||||||
|
|
||||||
|
const auto narrow = TextureAsset<GlbRGBA8>::LoadPNGFromMemory<std::uint8_t>(WidePng(16));
|
||||||
|
Check(narrow && narrow->sizeX == 16 && narrow->sizeY == 1, "the test's PNG decodes");
|
||||||
|
const auto wide = TextureAsset<GlbRGBA8>::LoadPNGFromMemory<std::uint8_t>(WidePng(70000));
|
||||||
|
Check(!wide, wide ? std::format("an image 70000 wide is an error, not {} wide", wide->sizeX) : "an image 70000 wide is an error");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column-major 4x4 helpers for the rest-pose check, kept separate from the
|
||||||
|
// matrix types under test.
|
||||||
|
using Mat = std::array<float, 16>;
|
||||||
|
|
||||||
|
Mat Multiply(const Mat& a, const Mat& b) {
|
||||||
|
Mat r{};
|
||||||
|
for (int col = 0; col < 4; ++col) {
|
||||||
|
for (int row = 0; row < 4; ++row) {
|
||||||
|
float sum = 0;
|
||||||
|
for (int k = 0; k < 4; ++k) sum += a[k * 4 + row] * b[col * 4 + k];
|
||||||
|
r[col * 4 + row] = sum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
Mat LocalMatrix(const GlbNode& node) {
|
||||||
|
if (node.hasMatrix) return node.matrix.Store(); // rows of the row-major copy are glTF's columns
|
||||||
|
const float x = node.rotation.x, y = node.rotation.y, z = node.rotation.z, w = node.rotation.w;
|
||||||
|
const float sx = node.scale.x, sy = node.scale.y, sz = node.scale.z;
|
||||||
|
return {
|
||||||
|
(1 - 2 * (y * y + z * z)) * sx, (2 * (x * y + w * z)) * sx, (2 * (x * z - w * y)) * sx, 0,
|
||||||
|
(2 * (x * y - w * z)) * sy, (1 - 2 * (x * x + z * z)) * sy, (2 * (y * z + w * x)) * sy, 0,
|
||||||
|
(2 * (x * z + w * y)) * sz, (2 * (y * z - w * x)) * sz, (1 - 2 * (x * x + y * y)) * sz, 0,
|
||||||
|
node.translation.x, node.translation.y, node.translation.z, 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestRealFile(const char* path) {
|
||||||
|
const auto loaded = GlbAsset::Load(std::filesystem::path(path));
|
||||||
|
Check(loaded.has_value(), loaded ? std::format("{} loads", path) : std::format("{} loads: {}", path, loaded.error()));
|
||||||
|
if (!loaded) return;
|
||||||
|
const GlbAsset& a = *loaded;
|
||||||
|
|
||||||
|
std::size_t vertices = 0;
|
||||||
|
bool streamsMatch = true;
|
||||||
|
bool indicesValid = true;
|
||||||
|
for (const GlbMesh& mesh : a.meshes) {
|
||||||
|
for (const GlbPrimitive& p : mesh.primitives) {
|
||||||
|
vertices += p.positions.size();
|
||||||
|
streamsMatch = streamsMatch && p.normals.size() == p.positions.size()
|
||||||
|
&& (p.joints.empty() || (p.joints.size() == p.positions.size() && p.weights.size() == p.positions.size()));
|
||||||
|
indicesValid = indicesValid && p.indices.size() % 3 == 0
|
||||||
|
&& std::ranges::all_of(p.indices, [&](std::uint32_t i) { return i < p.positions.size(); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::println(" {} meshes, {} vertices, {} nodes, {} skins ({} joints), {} materials, {} images",
|
||||||
|
a.meshes.size(), vertices, a.nodes.size(), a.skins.size(),
|
||||||
|
a.skins.empty() ? 0 : a.skins[0].joints.size(), a.materials.size(), a.images.size());
|
||||||
|
Check(streamsMatch, "every vertex stream has one entry per vertex");
|
||||||
|
Check(indicesValid, "every index list is whole triangles of valid vertices");
|
||||||
|
Check(std::ranges::all_of(a.images, [](const auto& image) { return image.sizeX > 0 && image.pixels.size() == std::size_t(image.sizeX) * image.sizeY; }),
|
||||||
|
"every image decodes");
|
||||||
|
|
||||||
|
// World matrices by walking down from the roots, then world * inverseBind
|
||||||
|
// per joint.
|
||||||
|
std::vector<Mat> world(a.nodes.size());
|
||||||
|
std::function<void(std::uint32_t, const Mat&)> walk = [&](std::uint32_t node, const Mat& parent) {
|
||||||
|
world[node] = Multiply(parent, LocalMatrix(a.nodes[node]));
|
||||||
|
for (std::uint32_t child : a.nodes[node].children) walk(child, world[node]);
|
||||||
|
};
|
||||||
|
const Mat identity = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1};
|
||||||
|
for (std::uint32_t r = 0; r < a.nodes.size(); ++r) {
|
||||||
|
if (a.nodes[r].parent == -1) walk(r, identity);
|
||||||
|
}
|
||||||
|
for (const GlbSkin& skin : a.skins) {
|
||||||
|
float worst = 0;
|
||||||
|
for (std::size_t j = 0; j < skin.joints.size(); ++j) {
|
||||||
|
const Mat bound = Multiply(world[skin.joints[j]], skin.inverseBindMatrices[j].Store());
|
||||||
|
for (std::size_t i = 0; i < 16; ++i) worst = std::max(worst, std::abs(bound[i] - identity[i]));
|
||||||
|
}
|
||||||
|
Check(worst < 1e-3f, std::format("skin '{}': world * inverseBind is the identity at rest (worst {})", skin.name, worst));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
TestFixture();
|
||||||
|
TestRoom();
|
||||||
|
TestBrokenFiles();
|
||||||
|
if (const char* real = std::getenv("CRAFTER_ASSET_TEST_GLB")) {
|
||||||
|
TestRealFile(real);
|
||||||
|
} else {
|
||||||
|
std::println("SKIP real-file check (set CRAFTER_ASSET_TEST_GLB to a skinned .glb)");
|
||||||
|
}
|
||||||
|
std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed");
|
||||||
|
return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue