diff --git a/implementations/Crafter.Asset-GLB.cpp b/implementations/Crafter.Asset-GLB.cpp index 40acfc8..b2694be 100644 --- a/implementations/Crafter.Asset-GLB.cpp +++ b/implementations/Crafter.Asset-GLB.cpp @@ -5,6 +5,8 @@ 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" +// errno, for why a file that exists doesn't open. +#include module Crafter.Asset; import std; import Crafter.Math; @@ -26,10 +28,43 @@ namespace { constexpr std::uint32_t kUnsignedInt = 5125; constexpr std::uint32_t kFloat = 5126; + // Primitive modes; those below kTriangles are points and lines. + constexpr std::int64_t kTriangles = 4; + constexpr std::int64_t kTriangleStrip = 5; + constexpr std::int64_t kTriangleFan = 6; + + // The decode limit when Load isn't given one: 1 GiB, the largest image a + // texture takes, and 64 bytes for each byte of the file, more than a + // model's vertices and images decode to unless the same data is decoded + // over and over. + constexpr std::uint64_t kDecodeFloor = std::uint64_t{1} << 30; + constexpr std::uint64_t kDecodePerByte = 64; + + // The extensions this loader implements. A file that requires another + // can't be loaded right without it, so, as glTF asks, it isn't loaded. + constexpr std::array kImplemented = { + "KHR_lights_punctual", "KHR_materials_emissive_strength", "KHR_materials_ior", "KHR_materials_transmission", + }; + + // An index the file gives that can't name anything, as GlbAsset's int32 + // keeps it; CheckReferences turns it down. + constexpr std::int32_t kBadIndex = std::numeric_limits::min(); + // A texCoord past the two UV sets a primitive keeps, likewise. + constexpr std::uint32_t kBadTexCoord = std::numeric_limits::max(); + + using Pixel = Vector; + using Image = TextureAsset; + std::unexpected Error(std::string message) { return std::unexpected("GLB: " + std::move(message)); } + std::uint32_t U32(std::span bytes, std::size_t offset) { + std::uint32_t value; + std::memcpy(&value, bytes.data() + offset, sizeof(value)); + return value; + } + // 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. @@ -39,9 +74,47 @@ namespace { return it == object.end() ? nullptr : &*it; } + // A whole number: 24, but also 24.0 or 2.4e1, as JSON may write one. + // One past int64, which no index or count reaches, comes back as + // int64's limit of its sign, which range checks still turn down. + std::optional AsInteger(const Json& value) { + if (value.is_number_unsigned()) { + return static_cast(std::min(value.get(), std::numeric_limits::max())); + } + if (value.is_number_integer()) return value.get(); + if (!value.is_number_float()) return std::nullopt; + const double number = value.get(); + if (number != std::trunc(number)) return std::nullopt; // a fraction, or NaN + constexpr double kTwoTo63 = 9223372036854775808.0; + if (number >= kTwoTo63) return std::numeric_limits::max(); + if (number < -kTwoTo63) return std::numeric_limits::min(); + return static_cast(number); + } + 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() : fallback; + return value != nullptr ? AsInteger(*value).value_or(fallback) : fallback; + } + + // An index into one of the file's arrays, as GlbAsset keeps it: -1 when + // `key` is absent, kBadIndex when it can't name an element (negative, + // not a whole number, past int32). Checked before it is narrowed, so + // that 4294967296 can't wrap round to 0. + std::int32_t Index(const Json& object, const char* key) { + const Json* value = Find(object, key); + if (value == nullptr) return -1; + const std::optional index = AsInteger(*value); + return index && *index >= 0 && *index <= std::numeric_limits::max() ? static_cast(*index) : kBadIndex; + } + + // An element of a list of indices (children, joints, a scene's nodes) + // that names one of `count`, or nullopt. + std::optional Element(const Json& value, std::size_t count) { + const std::optional index = AsInteger(value); + if (!index || *index < 0 || static_cast(*index) >= count || *index > std::numeric_limits::max()) { + return std::nullopt; + } + return static_cast(*index); } float Number(const Json& object, const char* key, float fallback) { @@ -65,10 +138,42 @@ namespace { return value != nullptr && value->is_array() ? *value : empty; } - // An object's extras as JSON text, or empty when it has none. + // An object's extras as JSON text, or empty when it has none. Written + // without recursion: Json::dump recurses once a level, and extras nested + // deep enough run it out of stack, where the parser and the destructor + // don't recurse. std::string Extras(const Json& object) { - const Json* value = Find(object, "extras"); - return value != nullptr ? value->dump() : std::string(); + const Json* extras = Find(object, "extras"); + if (extras == nullptr || extras->is_null()) return {}; + std::string text; + // The arrays and objects being written, each with its next element. + std::vector> open; + const Json* value = extras; + while (true) { + if (value != nullptr) { + if (value->is_structured() && !value->empty()) { + text += value->is_object() ? '{' : '['; + open.emplace_back(value, value->cbegin()); + } else { + text += value->dump(); // a scalar, {} or []: nothing nested + } + } + if (open.empty()) return text; + auto& [container, next] = open.back(); + if (next == container->cend()) { + text += container->is_object() ? '}' : ']'; + open.pop_back(); + value = nullptr; + continue; + } + if (next != container->cbegin()) text += ','; + if (container->is_object()) { + text += Json(next.key()).dump(); + text += ':'; + } + value = &*next; + ++next; + } } // The object an extension keeps in `object`'s extensions, or null. @@ -77,17 +182,16 @@ namespace { 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 - void Numbers(const Json& object, const char* key, std::array& out) { + // Numbers from a JSON array into `out`, which keeps its values where the + // array is shorter or holds something else. + void Numbers(const Json& object, const char* key, std::span out) { const Json& values = Array(object, key); - for (std::size_t i = 0; i < N && i < values.size(); ++i) { + for (std::size_t i = 0; i < out.size() && i < values.size(); ++i) { if (values[i].is_number()) out[i] = values[i].get(); } } - std::size_t ComponentSize(std::uint32_t componentType) { + std::size_t ComponentSize(std::int64_t componentType) { switch (componentType) { case kByte: case kUnsignedByte: return 1; case kShort: case kUnsignedShort: return 2; @@ -105,17 +209,24 @@ namespace { return 0; } - // An accessor resolved to its bytes in the BIN chunk. + // An accessor resolved to its bytes: in the BIN chunk, or, for one that + // is sparse or has no bufferView, in `dense`, where they were put + // together. struct Accessor { - const std::byte* data; - std::size_t count; - std::size_t stride; - std::uint32_t componentType; - std::uint32_t components; - bool normalized; + const std::byte* data = nullptr; + std::size_t count = 0; + std::size_t stride = 0; + std::uint32_t componentType = 0; + std::uint32_t components = 0; + bool normalized = false; + std::vector dense; + + const std::byte* At(std::size_t element, std::uint32_t component) const { + return (dense.empty() ? data : dense.data()) + element * stride + component * ComponentSize(componentType); + } float Float(std::size_t element, std::uint32_t component) const { - const std::byte* p = data + element * stride + component * ComponentSize(componentType); + const std::byte* p = At(element, component); 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; } @@ -128,7 +239,7 @@ namespace { } std::uint32_t Unsigned(std::size_t element, std::uint32_t component) const { - const std::byte* p = data + element * stride + component * ComponentSize(componentType); + const std::byte* p = At(element, component); 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; } @@ -141,12 +252,35 @@ namespace { struct Context { const Json& root; std::span bin; + // The decode limit, and what is left of it. + std::uint64_t limit; + std::uint64_t budget; }; + // Takes `count` elements of `size` bytes out of the budget; false when + // they don't fit in it, or in a size_t. + bool Spend(Context& ctx, std::uint64_t count, std::uint64_t size) { + if (count > ctx.budget / size || count > std::numeric_limits::max() / size) return false; + ctx.budget -= count * size; + return true; + } + + std::unexpected OverLimit(const Context& ctx, std::string_view what) { + return Error(std::format("{} decodes past the limit of {} bytes", what, ctx.limit)); + } + + // Sizes `stream` to `count` elements, out of the budget. + template + bool Allocate(Context& ctx, std::vector& stream, std::size_t count) { + if (!Spend(ctx, count, sizeof(T))) return false; + stream.resize(count); + return true; + } + // The bytes of buffer view `index`, which must lie in the BIN chunk. std::expected, 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(index) >= views.size()) { + if (index < 0 || static_cast(index) >= views.size()) { return Error(std::format("bufferView {} doesn't exist", index)); } const Json& view = views[static_cast(index)]; @@ -169,80 +303,170 @@ namespace { return ctx.bin.subspan(static_cast(offset), static_cast(length)); } - std::expected ReadAccessor(const Context& ctx, std::int64_t index) { + // `count` elements of `size` bytes, packed at the bufferView and + // byteOffset `object` names: a sparse accessor's indices or values. + std::expected, std::string> Packed(const Context& ctx, const Json& object, std::uint64_t count, std::size_t size) { + const auto view = ViewBytes(ctx, Integer(object, "bufferView", -1), nullptr); + if (!view) return std::unexpected(view.error()); + const std::int64_t offset = Integer(object, "byteOffset", 0); + const std::uint64_t available = view->size(); + if (offset < 0 || static_cast(offset) > available || count > (available - static_cast(offset)) / size) { + return Error("sparse data reads past the end of its bufferView"); + } + return view->subspan(static_cast(offset), static_cast(count * size)); + } + + // A sparse accessor's values, written over the elements its indices name + // in `accessor`, whose elements are already in `dense`. + std::expected ApplySparse(const Context& ctx, const Json& sparse, Accessor& accessor, std::int64_t index) { + const std::int64_t count = Integer(sparse, "count", -1); + const Json* indices = Find(sparse, "indices"); + const Json* values = Find(sparse, "values"); + const std::int64_t indexType = indices != nullptr ? Integer(*indices, "componentType", 0) : 0; + if (count < 0 || static_cast(count) > accessor.count || values == nullptr + || (indexType != kUnsignedByte && indexType != kUnsignedShort && indexType != kUnsignedInt)) { + return Error(std::format("accessor {} has a malformed sparse part", index)); + } + const std::size_t indexSize = ComponentSize(indexType); + const auto indexBytes = Packed(ctx, *indices, static_cast(count), indexSize); + if (!indexBytes) return std::unexpected(std::format("{} (sparse indices of accessor {})", indexBytes.error(), index)); + const std::size_t elementSize = accessor.stride; + const auto valueBytes = Packed(ctx, *values, static_cast(count), elementSize); + if (!valueBytes) return std::unexpected(std::format("{} (sparse values of accessor {})", valueBytes.error(), index)); + + const Accessor elements{ + .data = indexBytes->data(), + .count = static_cast(count), + .stride = indexSize, + .componentType = static_cast(indexType), + .components = 1, + }; + std::int64_t previous = -1; + for (std::size_t i = 0; i < elements.count; ++i) { + const std::uint32_t element = elements.Unsigned(i, 0); + // glTF has them strictly increase, which also keeps each one + // inside the accessor once. + if (element <= previous || element >= accessor.count) { + return Error(std::format("accessor {}'s sparse indices don't rise within its {} elements", index, accessor.count)); + } + previous = element; + std::memcpy(accessor.dense.data() + std::size_t{element} * elementSize, valueBytes->data() + i * elementSize, elementSize); + } + return {}; + } + + std::expected ReadAccessor(Context& ctx, std::int64_t index) { const Json& accessors = Array(ctx.root, "accessors"); - if (index < 0 || static_cast(index) >= accessors.size()) { + if (index < 0 || static_cast(index) >= accessors.size()) { return Error(std::format("accessor {} doesn't exist", index)); } const Json& accessor = accessors[static_cast(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(Integer(accessor, "componentType", 0)); + // Compared as the file has it, so that 4294972422 can't wrap round + // to FLOAT, 5126. + const std::int64_t componentType = Integer(accessor, "componentType", 0); + const std::size_t componentSize = ComponentSize(componentType); const std::uint32_t components = ComponentCount(String(accessor, "type")); - if (ComponentSize(componentType) == 0 || components == 0) { + if (componentSize == 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) { + if (components == 16 && componentSize != 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(offset) <= size - && (count == 0 || (elementSize <= size - static_cast(offset) - && static_cast(count - 1) <= (size - static_cast(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(count), - .stride = stride, - .componentType = componentType, + const std::size_t elementSize = componentSize * components; + Accessor result{ + .stride = elementSize, + .componentType = static_cast(componentType), .components = components, .normalized = Boolean(accessor, "normalized", false), }; + + const bool hasView = Find(accessor, "bufferView") != nullptr; + if (hasView) { + std::size_t viewStride = 0; + const auto view = ViewBytes(ctx, Integer(accessor, "bufferView", -1), &viewStride); + if (!view) return std::unexpected(view.error()); + 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(offset) <= size + && (count == 0 || (elementSize <= size - static_cast(offset) + && static_cast(count - 1) <= (size - static_cast(offset) - elementSize) / stride)); + if (!inside) { + return Error(std::format("accessor {} reads past the end of its bufferView", index)); + } + result.data = view->data() + offset; + result.stride = stride; + } + const Json* sparse = Find(accessor, "sparse"); + if (hasView && sparse == nullptr) { + result.count = static_cast(count); // inside its view, so a size_t holds it + return result; + } + + // Put together in `dense`: the view's elements, or zeros without one, + // as glTF says, with the sparse values written over them. + if (!Spend(ctx, static_cast(count), elementSize)) return OverLimit(ctx, std::format("accessor {}", index)); + result.count = static_cast(count); + result.dense.resize(result.count * elementSize); + if (hasView) { + for (std::size_t i = 0; i < result.count; ++i) { + std::memcpy(result.dense.data() + i * elementSize, result.data + i * result.stride, elementSize); + } + } + result.stride = elementSize; + if (sparse != nullptr) { + if (auto applied = ApplySparse(ctx, *sparse, result, index); !applied) return std::unexpected(applied.error()); + } + return result; } // An accessor of `components` per element whose component type is one of - // `allowed`, with exactly `count` elements unless count is 0. - std::expected Attribute(const Context& ctx, const Json& attributes, const char* name, std::uint32_t components, - std::initializer_list allowed, std::size_t count, const std::string& where) { + // `allowed`, with exactly `count` elements when a count is given. + std::expected Attribute(Context& ctx, const Json& attributes, const char* name, std::uint32_t components, + std::initializer_list allowed, std::optional 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)); + if (count && accessor->count != *count) { + return Error(std::format("{} of {} has {} elements for {} vertices", name, where, accessor->count, *count)); } return accessor; } - // Per-vertex tangents from the UV gradients, accumulated over the + // The vertex stream `name` into `out`: `components` numbers for each of + // the `count` vertices. Left empty when `attributes` doesn't have it. + template + std::expected Stream(Context& ctx, const Json& attributes, const char* name, std::uint32_t components, + std::initializer_list allowed, std::size_t count, const std::string& where, + std::vector& out) { + if (Find(attributes, name) == nullptr) return {}; + const auto accessor = Attribute(ctx, attributes, name, components, allowed, count, where); + if (!accessor) return std::unexpected(accessor.error()); + if (!Allocate(ctx, out, count)) return OverLimit(ctx, where); + for (std::size_t i = 0; i < count; ++i) { + for (std::uint32_t c = 0; c < components; ++c) out[i].v[c] = accessor->Float(i, c); + } + return {}; + } + + // Per-vertex tangents from the gradients of `uv`, 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) { + void ComputeTangents(GlbPrimitive& p, const std::vector>& uv) { using V3 = Vector; const std::size_t n = p.positions.size(); std::vector tangentSum(n, V3(0, 0, 0)); @@ -252,8 +476,8 @@ namespace { 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 du1 = uv[i[1]].x - uv[i[0]].x, dv1 = uv[i[1]].y - uv[i[0]].y; + const float du2 = uv[i[2]].x - uv[i[0]].x, dv2 = uv[i[2]].y - uv[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; @@ -268,10 +492,13 @@ namespace { 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) { + // Written so that NaN takes these branches too. + if (!(tangent.LengthSq() >= 1e-20f)) { // No usable UV gradient: any direction perpendicular to the - // normal keeps the frame valid. + // normal keeps the frame valid, and any at all where the + // normal is zero. tangent = V3::Cross(std::abs(normal.x) < 0.9f ? V3(1, 0, 0) : V3(0, 1, 0), normal); + if (!(tangent.LengthSq() >= 1e-20f)) tangent = V3(1, 0, 0); } tangent.Normalize(); const float sign = V3::Dot(V3::Cross(normal, tangent), bitangentSum[v]) < 0 ? 1.0f : -1.0f; @@ -279,72 +506,109 @@ namespace { } } - std::expected 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))); + // JOINTS_n and WEIGHTS_n, which come in pairs from n = 0 up, as up to + // four influences a vertex: the heaviest, a joint named twice counted + // once with its weights added, and the weights made to sum to 1. + std::expected ReadInfluences(Context& ctx, const Json& attributes, std::size_t n, const std::string& where, GlbPrimitive& p) { + std::size_t sets = 0; + while (Find(attributes, std::format("JOINTS_{}", sets).c_str()) != nullptr + && Find(attributes, std::format("WEIGHTS_{}", sets).c_str()) != nullptr) { + ++sets; } + // Every JOINTS_ and WEIGHTS_ is one of those pairs: no half of one, + // and no gap before one. + std::size_t named = 0; + for (auto it = attributes.begin(); it != attributes.end(); ++it) { + named += it.key().starts_with("JOINTS_") || it.key().starts_with("WEIGHTS_"); + } + if (named != 2 * sets) return Error(std::format("{}'s JOINTS_n and WEIGHTS_n don't pair up from n = 0", where)); + if (sets == 0) return {}; + + std::vector> influences; + for (std::size_t set = 0; set < sets; ++set) { + const std::string joints = std::format("JOINTS_{}", set); + const std::string weights = std::format("WEIGHTS_{}", set); + auto j = Attribute(ctx, attributes, joints.c_str(), 4, {kUnsignedByte, kUnsignedShort}, n, where); + if (!j) return std::unexpected(j.error()); + auto w = Attribute(ctx, attributes, weights.c_str(), 4, {kFloat, kUnsignedByte, kUnsignedShort}, n, where); + if (!w) return std::unexpected(w.error()); + influences.emplace_back(std::move(*j), std::move(*w)); + } + // Each set is read for every vertex, so each beyond the first is + // paid for as if it were kept too. + if (!Spend(ctx, static_cast(n) * (sets - 1), sizeof(std::array) + sizeof(std::array)) + || !Allocate(ctx, p.joints, n) || !Allocate(ctx, p.weights, n)) { + return OverLimit(ctx, where); + } + + using Influence = std::pair; + std::vector heaviest; + for (std::size_t v = 0; v < n; ++v) { + heaviest.clear(); + for (const auto& [joints, weights] : influences) { + for (std::uint32_t c = 0; c < 4; ++c) { + const float weight = weights.Float(v, c); + // A weight of nothing: zero, negative, or not a number. + if (!(weight > 0.0f) || !std::isfinite(weight)) continue; + const auto joint = static_cast(joints.Unsigned(v, c)); + const auto same = std::ranges::find(heaviest, joint, &Influence::first); + if (same != heaviest.end()) same->second += weight; + else heaviest.emplace_back(joint, weight); + } + } + if (heaviest.empty()) { + p.joints[v][0] = static_cast(influences[0].first.Unsigned(v, 0)); + p.weights[v][0] = 1.0f; + continue; + } + if (heaviest.size() > 4) { + std::ranges::stable_sort(heaviest, std::ranges::greater(), &Influence::second); + heaviest.resize(4); + } + float total = 0; + for (const auto& [joint, weight] : heaviest) total += weight; + for (std::size_t k = 0; k < heaviest.size(); ++k) { + p.joints[v][k] = heaviest[k].first; + p.weights[v][k] = heaviest[k].second / total; + } + } + return {}; + } + + std::expected ReadPrimitive(Context& ctx, const Json& primitive, const std::vector& materials, + const std::string& where) { + GlbPrimitive p; + p.material = Index(primitive, "material"); + const std::int64_t mode = Integer(primitive, "mode", kTriangles); + if (mode < 0 || mode > kTriangleFan) return Error(std::format("{} has mode {}, which glTF doesn't define", where, mode)); + // Points and lines: no triangles to read. + if (mode < kTriangles) return p; 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); + const auto positions = Attribute(ctx, *attributes, "POSITION", 3, {kFloat}, std::nullopt, 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 (n > std::numeric_limits::max()) return Error(std::format("{} has more vertices than 32-bit indices reach", where)); + if (!Allocate(ctx, p.positions, n)) return OverLimit(ctx, where); + for (std::size_t i = 0; i < n; ++i) { + for (std::uint32_t c = 0; c < 3; ++c) p.positions[i].v[c] = positions->Float(i, c); } - 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(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 (auto read = Stream(ctx, *attributes, "NORMAL", 3, {kFloat}, n, where, p.normals); !read) return std::unexpected(read.error()); + for (const auto& [name, set] : { std::pair { "TEXCOORD_0", &p.uv0 }, std::pair { "TEXCOORD_1", &p.uv1 } }) { + if (auto read = Stream(ctx, *attributes, name, 2, {kFloat, kUnsignedByte, kUnsignedShort}, n, where, *set); !read) { + return std::unexpected(read.error()); } } + if (auto read = Stream(ctx, *attributes, "TANGENT", 4, {kFloat}, n, where, p.tangents); !read) return std::unexpected(read.error()); + if (auto read = ReadInfluences(ctx, *attributes, n, where, p); !read) return std::unexpected(read.error()); + // The vertices in the order the file lists them, then as a list of + // triangles. + std::vector listed; 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)); @@ -352,120 +616,231 @@ namespace { && 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)); + if (!Allocate(ctx, listed, indices->count)) return OverLimit(ctx, where); + for (std::size_t i = 0; i < indices->count; ++i) listed[i] = indices->Unsigned(i, 0); } else { - p.indices.resize(n); - std::iota(p.indices.begin(), p.indices.end(), 0u); + if (!Allocate(ctx, listed, n)) return OverLimit(ctx, where); + std::iota(listed.begin(), listed.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; })) { + if (std::ranges::any_of(listed, [n](std::uint32_t i) { return i >= n; })) { return Error(std::format("{} indexes past its {} vertices", where, n)); } + if (mode == kTriangles) { + if (listed.size() % 3 != 0) { + return Error(std::format("{} has {} indices, not whole triangles", where, listed.size())); + } + p.indices = std::move(listed); + } else { + // A strip's or a fan's triangles as glTF numbers them: a strip's + // odd ones swap their last two corners to keep the winding, and + // a fan's all end at its first vertex. + const std::size_t triangles = listed.size() >= 3 ? listed.size() - 2 : 0; + if (!Allocate(ctx, p.indices, triangles * 3)) return OverLimit(ctx, where); + for (std::size_t t = 0; t < triangles; ++t) { + const std::array corners = mode == kTriangleStrip ? std::array{t, t + 1 + t % 2, t + 2 - t % 2} + : std::array{t + 1, t + 2, 0}; + for (std::size_t c = 0; c < 3; ++c) p.indices[t * 3 + c] = listed[corners[c]]; + } + } - p.material = static_cast(Integer(primitive, "material", -1)); - if (p.tangents.empty() && !p.normals.empty() && !p.uv0.empty()) ComputeTangents(p); + const Json& targets = Array(primitive, "targets"); + p.targets.resize(targets.size()); + for (std::size_t t = 0; t < targets.size(); ++t) { + const std::string target = std::format("{} morph target {}", where, t); + GlbMorphTarget& morph = p.targets[t]; + for (const auto& [name, stream] : { std::pair { "POSITION", &morph.positions }, std::pair { "NORMAL", &morph.normals }, + std::pair { "TANGENT", &morph.tangents } }) { + if (auto read = Stream(ctx, targets[t], name, 3, {kFloat}, n, target, *stream); !read) return std::unexpected(read.error()); + } + } + + // Tangents the file leaves out, from the UV set its normal map is + // sampled with, as glTF asks. + if (p.tangents.empty() && !p.normals.empty()) { + std::uint32_t set = 0; + if (p.material >= 0 && static_cast(p.material) < materials.size()) { + const GlbTextureRef& normalTexture = materials[static_cast(p.material)].normalTexture; + if (normalTexture.texture != -1) set = normalTexture.texCoord; + } + const std::vector>* uv = set == 0 ? &p.uv0 : set == 1 ? &p.uv1 : nullptr; + if (uv != nullptr && !uv->empty()) { + if (!Spend(ctx, n, sizeof(Vector))) return OverLimit(ctx, where); + ComputeTangents(p, *uv); + } + } 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(Integer(*info, "index", -1)); - ref.texCoord = static_cast(Integer(*info, "texCoord", 0)); - if (scaleKey != nullptr) ref.scale = Number(*info, scaleKey, 1.0f); + ref.texture = Index(*info, "index"); + const std::int64_t texCoord = Integer(*info, "texCoord", ref.texCoord); + ref.texCoord = texCoord == 0 || texCoord == 1 ? static_cast(texCoord) : kBadTexCoord; + if (scaleKey != nullptr) ref.scale = Number(*info, scaleKey, ref.scale); } return ref; } - // Each index points at an element that exists, so callers can index - // without checking. + // Core glTF's image formats, PNG and JPEG, told by their first bytes. + // stb_image reads more (GIF, PSD, TGA, ...), which have no business in a + // GLB, so those don't reach it. + bool PngOrJpeg(std::span bytes) { + constexpr std::array kPng = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'}; + constexpr std::array kJpeg = {0xFF, 0xD8, 0xFF}; + auto startsWith = [bytes](std::span magic) { + return bytes.size() >= magic.size() && std::memcmp(bytes.data(), magic.data(), magic.size()) == 0; + }; + return startsWith(kPng) || startsWith(kJpeg); + } + + // A sampler's enum, which must be one of `allowed`: `fallback` when the + // file leaves it out, nullopt when it is anything else. + std::optional Enum(const Json& sampler, const char* key, std::uint32_t fallback, std::initializer_list allowed) { + if (Find(sampler, key) == nullptr) return fallback; + const std::int64_t value = Integer(sampler, key, -1); + if (std::ranges::find(allowed, value) == allowed.end()) return std::nullopt; + return static_cast(value); + } + + // Each index points at an element that exists, and what the interface + // promises of them holds, so callers can index without checking. std::expected CheckReferences(const GlbAsset& a) { - auto valid = [](std::int64_t index, std::size_t size) { return index == -1 || (index >= 0 && static_cast(index) < size); }; + auto valid = [](std::int64_t index, std::size_t size) { return index == -1 || (index >= 0 && static_cast(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)); + const GlbNode& node = a.nodes[i]; + if (!valid(node.mesh, a.meshes.size())) return Error(std::format("node {} uses a mesh that doesn't exist", i)); + if (!valid(node.skin, a.skins.size())) return Error(std::format("node {} uses a skin that doesn't exist", i)); + if (!valid(node.camera, a.cameras.size())) return Error(std::format("node {} uses a camera that doesn't exist", i)); + if (!valid(node.light, a.lights.size())) return Error(std::format("node {} uses a light that doesn't exist", i)); + if (node.skin != -1 && node.mesh == -1) return Error(std::format("node {} has a skin and no mesh to skin", i)); + if (!node.weights.empty() && (node.mesh == -1 || node.weights.size() != a.meshes[static_cast(node.mesh)].weights.size())) { + return Error(std::format("node {} has {} morph weights, not one for each of its mesh's targets", i, node.weights.size())); + } } - 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)); + 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)); + if (ref->texture != -1 && ref->texCoord == kBadTexCoord) { + return Error(std::format("material '{}' samples a UV set other than TEXCOORD_0 and TEXCOORD_1, the ones the loader reads", m.name)); + } + } + } + // For each mesh, once however many nodes draw it: its highest joint + // index, and whether every primitive with vertices has joints. + std::vector highestJoint(a.meshes.size(), -1); + std::vector skinnable(a.meshes.size(), true); + for (std::size_t m = 0; m < a.meshes.size(); ++m) { + for (std::size_t p = 0; p < a.meshes[m].primitives.size(); ++p) { + const GlbPrimitive& primitive = a.meshes[m].primitives[p]; + if (!valid(primitive.material, a.materials.size())) { + return Error(std::format("mesh '{}' uses a material that doesn't exist", a.meshes[m].name)); + } + if (primitive.positions.empty()) continue; // points, lines, or nothing to draw + if (primitive.material != -1) { + const GlbMaterial& material = a.materials[static_cast(primitive.material)]; + for (const GlbTextureRef* ref : {&material.baseColorTexture, &material.metallicRoughnessTexture, &material.normalTexture, + &material.occlusionTexture, &material.emissiveTexture, &material.transmissionTexture}) { + if (ref->texture != -1 && (ref->texCoord == 0 ? primitive.uv0 : primitive.uv1).empty()) { + return Error(std::format("material '{}' samples TEXCOORD_{}, which mesh {} primitive {} doesn't have", material.name, + ref->texCoord, m, p)); + } + } + } + skinnable[m] = skinnable[m] && !primitive.joints.empty(); + for (const auto& joints : primitive.joints) { + for (std::uint16_t joint : joints) highestJoint[m] = std::max(highestJoint[m], joint); + } } } // 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 mesh = static_cast(node.mesh); + if (!skinnable[mesh]) { + return Error(std::format("node '{}' skins mesh '{}', which has a primitive without JOINTS_0 and WEIGHTS_0", node.name, a.meshes[mesh].name)); + } const std::size_t jointCount = a.skins[static_cast(node.skin)].joints.size(); - for (const GlbPrimitive& p : a.meshes[static_cast(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(node.mesh)].name, jointCount)); - } - } + if (highestJoint[mesh] >= static_cast(jointCount)) { + return Error(std::format("node '{}' skins mesh '{}' with joint indices beyond its skin's {} joints", node.name, a.meshes[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)); + const GlbTexture& texture = a.textures[i]; + if (!valid(texture.image, a.images.size())) return Error(std::format("texture {} uses an image that doesn't exist", i)); + if (texture.image != -1 && a.images[static_cast(texture.image)].pixels.empty()) { + return Error(std::format("texture {} uses image {}, which is neither a PNG nor a JPEG", i, texture.image)); } + if (!valid(texture.sampler, a.samplers.size())) return Error(std::format("texture {} uses a sampler that doesn't exist", i)); } return {}; } + + // The 12-byte header: the length the file says it has, no less than the + // header's own. + std::expected Header(std::span glb) { + if (glb.size() < 12) return Error("too short to be a GLB"); + if (U32(glb, 0) != kMagic) return Error("bad magic, not a .glb"); + if (U32(glb, 4) != 2) return Error(std::format("container version {}, only 2 is supported", U32(glb, 4))); + if (U32(glb, 8) < 12) return Error(std::format("the header says the file has {} bytes, fewer than the header", U32(glb, 8))); + return U32(glb, 8); + } } -std::expected GlbAsset::Load(const fs::path& path) { - // A directory or a pipe opens too, with no size to read. +std::expected GlbAsset::Load(const fs::path& path, std::optional decodeLimit) { std::error_code error; - if (!fs::is_regular_file(path, error)) return Error(std::format("{} isn't a file", path.string())); + const fs::file_status status = fs::status(path, error); + if (error) return Error(std::format("can't read {}: {}", path.string(), error.message())); + // A directory or a pipe opens too, with no size to read. + if (!fs::is_regular_file(status)) 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())); + errno = 0; std::ifstream file(path, std::ios::binary); - if (!file) return Error(std::format("can't open {}", path.string())); - std::vector bytes(static_cast(size)); - if (!file.read(reinterpret_cast(bytes.data()), static_cast(bytes.size()))) { + if (!file) { + return Error(std::format("can't open {}: {}", path.string(), errno != 0 ? std::generic_category().message(errno) : "reason unknown")); + } + // The header first. A file that isn't a GLB is turned down unread, and + // one that is says how much of it to read, which can't pass 4 GiB + // however large the file. + std::array header; + if (size < header.size() || !file.read(reinterpret_cast(header.data()), header.size())) { + return Error(std::format("too short to be a GLB ({})", path.string())); + } + const auto length = Header(header); + if (!length) return std::unexpected(std::format("{} ({})", length.error(), path.string())); + if (*length > size) return Error(std::format("truncated: the header says {} bytes, there are {} ({})", *length, size, path.string())); + std::vector bytes(*length); + std::memcpy(bytes.data(), header.data(), header.size()); + if (!file.read(reinterpret_cast(bytes.data() + header.size()), static_cast(bytes.size() - header.size()))) { return Error(std::format("can't read {}", path.string())); } - auto asset = Load(std::span(bytes)); + auto asset = Load(std::span(bytes), decodeLimit); if (!asset) return std::unexpected(std::format("{} ({})", asset.error(), path.string())); return asset; } -std::expected GlbAsset::Load(std::span 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); +std::expected GlbAsset::Load(std::span glb, std::optional decodeLimit) { + const auto header = Header(glb); + if (!header) return std::unexpected(header.error()); + const std::uint32_t length = *header; 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. + // as the spec asks. The arithmetic is in 32 bits, like the header's, and + // ordered so that it can't wrap round. std::span jsonChunk; std::span 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. + for (std::uint32_t offset = 12; length - offset >= 8;) { + const std::uint32_t chunkLength = U32(glb, offset); + const std::uint32_t chunkType = U32(glb, offset + 4); + // Not offset + 8 + chunkLength > length, which wraps round. if (chunkLength > length - offset - 8) return Error("truncated chunk"); const std::span chunk = glb.subspan(offset + 8, chunkLength); if (chunkType == kChunkJson && jsonChunk.empty()) jsonChunk = chunk; @@ -480,25 +855,29 @@ std::expected GlbAsset::Load(std::span g 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}; + for (const Json& name : Array(root, "extensionsRequired")) { + if (!name.is_string()) return Error("extensionsRequired names something that isn't an extension"); + if (std::ranges::find(kImplemented, name.get_ref()) == kImplemented.end()) { + return Error(std::format("the file requires {}, which this loader doesn't implement", name.get_ref())); + } + } + const std::uint64_t limit = decodeLimit.value_or(kDecodeFloor + kDecodePerByte * length); + Context ctx{.root = root, .bin = binChunk, .limit = limit, .budget = limit}; // Nodes, then the parent links their children lists imply. - for (const Json& node : Array(root, "nodes")) { + const Json& nodes = Array(root, "nodes"); + for (std::size_t i = 0; i < nodes.size(); ++i) { + const Json& node = nodes[i]; 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()); + const std::optional index = Element(child, nodes.size()); + if (!index) return Error(std::format("node {} has a child that isn't one of the {} nodes", i, nodes.size())); + n.children.push_back(*index); } - std::array t = {0, 0, 0}; - std::array r = {0, 0, 0, 1}; - std::array s = {1, 1, 1}; - Numbers(node, "translation", t); - Numbers(node, "rotation", r); - Numbers(node, "scale", s); - n.translation = Vector(t[0], t[1], t[2]); - n.rotation = Vector(r[0], r[1], r[2], r[3]); - n.scale = Vector(s[0], s[1], s[2]); + Numbers(node, "translation", n.translation.v); + Numbers(node, "rotation", n.rotation.v); + Numbers(node, "scale", n.scale.v); if (Array(node, "matrix").size() == 16) { std::array m = {}; Numbers(node, "matrix", m); @@ -506,17 +885,17 @@ std::expected GlbAsset::Load(std::span g n.matrix = MatrixRowMajor(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(Integer(node, "mesh", -1)); - n.skin = static_cast(Integer(node, "skin", -1)); - n.camera = static_cast(Integer(node, "camera", -1)); + n.mesh = Index(node, "mesh"); + n.skin = Index(node, "skin"); + n.camera = Index(node, "camera"); if (const Json* punctual = Extension(node, "KHR_lights_punctual")) { - n.light = static_cast(Integer(*punctual, "light", -1)); + n.light = Index(*punctual, "light"); } + for (const Json& weight : Array(node, "weights")) n.weights.push_back(weight.is_number() ? weight.get() : 0.0f); 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(i); } @@ -538,14 +917,22 @@ std::expected GlbAsset::Load(std::span g for (std::size_t node : walked) seen[node] = Seen::ReachesRoot; } + // The default scene, or the first when the file names none. const Json& scenes = Array(root, "scenes"); - const std::int64_t scene = Integer(root, "scene", 0); - if (scene >= 0 && static_cast(scene) < scenes.size()) { + std::int64_t scene = Index(root, "scene"); + if (scene == -1 && !scenes.empty()) scene = 0; + if (scene != -1) { + if (scene < 0 || static_cast(scene) >= scenes.size()) { + return Error(std::format("the default scene isn't one of the {} scenes", scenes.size())); + } + std::vector isRoot(asset.nodes.size(), false); for (const Json& node : Array(scenes[static_cast(scene)], "nodes")) { - if (!node.is_number_unsigned() || node.get() >= asset.nodes.size()) { - return Error("the default scene names a node that doesn't exist"); - } - asset.sceneRoots.push_back(node.get()); + const std::optional index = Element(node, asset.nodes.size()); + if (!index) return Error("the default scene names a node that doesn't exist"); + if (asset.nodes[*index].parent != -1) return Error(std::format("the default scene has node {}, a child, as a root", *index)); + if (isRoot[*index]) return Error(std::format("the default scene names node {} twice", *index)); + isRoot[*index] = true; + asset.sceneRoots.push_back(*index); } asset.extras = Extras(scenes[static_cast(scene)]); } else { @@ -554,26 +941,72 @@ std::expected GlbAsset::Load(std::span g } } + // Materials before meshes: a primitive's tangents depend on its + // material's normal texture. + 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", m.metallicFactor); + m.roughnessFactor = Number(*pbr, "roughnessFactor", m.roughnessFactor); + 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", m.alphaCutoff); + m.doubleSided = Boolean(material, "doubleSided", m.doubleSided); + if (const Json* strength = Extension(material, "KHR_materials_emissive_strength")) { + m.emissiveStrength = Number(*strength, "emissiveStrength", m.emissiveStrength); + } + if (const Json* transmission = Extension(material, "KHR_materials_transmission")) { + m.transmissionFactor = Number(*transmission, "transmissionFactor", m.transmissionFactor); + m.transmissionTexture = TextureRef(*transmission, "transmissionTexture", nullptr); + } + if (const Json* ior = Extension(material, "KHR_materials_ior")) { + m.ior = Number(*ior, "ior", m.ior); + } + m.extras = Extras(material); + } + 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"); + // glTF has every primitive of a mesh carry the same morph targets, + // which the mesh's weights weigh. + const std::size_t targets = primitives.empty() ? 0 : Array(primitives[0], "targets").size(); for (std::size_t p = 0; p < primitives.size(); ++p) { - auto primitive = ReadPrimitive(ctx, primitives[p], std::format("mesh {} primitive {}", m, p)); + if (Array(primitives[p], "targets").size() != targets) { + return Error(std::format("mesh {}'s primitives don't have the same number of morph targets", m)); + } + auto primitive = ReadPrimitive(ctx, primitives[p], asset.materials, std::format("mesh {} primitive {}", m, p)); if (!primitive) return std::unexpected(primitive.error()); mesh.primitives.push_back(std::move(*primitive)); } + mesh.weights.assign(targets, 0.0f); + if (Find(meshes[m], "weights") != nullptr && Array(meshes[m], "weights").size() != targets) { + return Error(std::format("mesh {} has {} morph weights for {} targets", m, Array(meshes[m], "weights").size(), targets)); + } + Numbers(meshes[m], "weights", mesh.weights); + mesh.extras = Extras(meshes[m]); } 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()); + const std::optional index = Element(joint, asset.nodes.size()); + if (!index) return Error(std::format("skin '{}' has a joint that isn't one of the {} nodes", s.name, asset.nodes.size())); + s.joints.push_back(*index); } - s.skeleton = static_cast(Integer(skin, "skeleton", -1)); + s.skeleton = Index(skin, "skeleton"); 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)); @@ -592,51 +1025,20 @@ std::expected GlbAsset::Load(std::span g } } - 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); + c.xmag = Number(*orthographic, "xmag", c.xmag); + c.ymag = Number(*orthographic, "ymag", c.ymag); + c.znear = Number(*orthographic, "znear", c.znear); + c.zfar = Number(*orthographic, "zfar", c.zfar); } 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); + c.yfov = Number(*perspective, "yfov", c.yfov); + c.aspectRatio = Number(*perspective, "aspectRatio", c.aspectRatio); + c.znear = Number(*perspective, "znear", c.znear); + c.zfar = Number(*perspective, "zfar", c.zfar); } else { return Error(std::format("camera '{}' has no {} properties", c.name, c.orthographic ? "orthographic" : "perspective")); } @@ -652,8 +1054,8 @@ std::expected GlbAsset::Load(std::span g 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); + l.intensity = Number(light, "intensity", l.intensity); + l.range = Number(light, "range", l.range); if (const Json* spot = Find(light, "spot")) { l.innerConeAngle = Number(*spot, "innerConeAngle", l.innerConeAngle); l.outerConeAngle = Number(*spot, "outerConeAngle", l.outerConeAngle); @@ -664,31 +1066,55 @@ std::expected GlbAsset::Load(std::span g for (const Json& texture : Array(root, "textures")) { asset.textures.push_back({ - .image = static_cast(Integer(texture, "source", -1)), - .sampler = static_cast(Integer(texture, "sampler", -1)), + .image = Index(texture, "source"), + .sampler = Index(texture, "sampler"), }); } - for (const Json& sampler : Array(root, "samplers")) { - asset.samplers.push_back({ - .magFilter = static_cast(Integer(sampler, "magFilter", 0)), - .minFilter = static_cast(Integer(sampler, "minFilter", 0)), - .wrapS = static_cast(Integer(sampler, "wrapS", 10497)), - .wrapT = static_cast(Integer(sampler, "wrapT", 10497)), - }); + const Json& samplers = Array(root, "samplers"); + for (std::size_t i = 0; i < samplers.size(); ++i) { + GlbSampler& s = asset.samplers.emplace_back(); + const auto magFilter = Enum(samplers[i], "magFilter", s.magFilter, {9728, 9729}); + const auto minFilter = Enum(samplers[i], "minFilter", s.minFilter, {9728, 9729, 9984, 9985, 9986, 9987}); + const auto wrapS = Enum(samplers[i], "wrapS", s.wrapS, {33071, 33648, 10497}); + const auto wrapT = Enum(samplers[i], "wrapT", s.wrapT, {33071, 33648, 10497}); + if (!magFilter || !minFilter || !wrapS || !wrapT) return Error(std::format("sampler {} has a filter or wrap mode glTF doesn't define", i)); + s = {.magFilter = *magFilter, .minFilter = *minFilter, .wrapS = *wrapS, .wrapT = *wrapT}; } const Json& images = Array(root, "images"); + // The image that decoded each bufferView first: another naming it is + // copied rather than decoded again. + std::unordered_map decodedFrom; 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); + const std::int64_t view = Integer(images[i], "bufferView", -1); + const auto bytes = ViewBytes(ctx, view, nullptr); if (!bytes) return std::unexpected(std::format("{} (image {})", bytes.error(), i)); - auto image = TextureAsset::LoadPNGFromMemory(*bytes); - if (!image) return Error(std::format("image {} doesn't decode: {}", i, image.error())); - asset.images.push_back(std::move(*image)); + Image& image = asset.images.emplace_back(); + // An extension's format (EXT_texture_webp's WebP, KHR_texture_basisu's + // KTX2, ...) stays undecoded; CheckReferences turns down a texture + // that has one as its image. + if (!PngOrJpeg(*bytes)) continue; + if (const auto same = decodedFrom.find(view); same != decodedFrom.end()) { + const Image& decoded = asset.images[same->second]; + if (!Spend(ctx, decoded.pixels.size(), sizeof(Pixel))) return OverLimit(ctx, std::format("image {}", i)); + image = decoded; + continue; + } + // Its size from its header, so that it is paid for before it is + // decoded. + const auto size = Image::SizeFromMemory(*bytes); + if (!size) return Error(std::format("image {} doesn't decode: {}", i, size.error())); + if (!Spend(ctx, std::uint64_t{size->first} * size->second, sizeof(Pixel))) return OverLimit(ctx, std::format("image {}", i)); + auto decoded = Image::LoadPNGFromMemory(*bytes); + if (!decoded) return Error(std::format("image {} doesn't decode: {}", i, decoded.error())); + image = std::move(*decoded); + decodedFrom.emplace(view, i); } if (auto valid = CheckReferences(asset); !valid) return std::unexpected(valid.error()); + asset.bin.assign(binChunk.begin(), binChunk.end()); return asset; } diff --git a/interfaces/Crafter.Asset-GLB.cppm b/interfaces/Crafter.Asset-GLB.cppm index b58c98c..545baab 100644 --- a/interfaces/Crafter.Asset-GLB.cppm +++ b/interfaces/Crafter.Asset-GLB.cppm @@ -8,22 +8,24 @@ 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. +// triangle primitives with their vertex streams and morph targets, skins, +// materials, textures and decoded images, cameras, and the lights of +// KHR_lights_punctual. The JSON and BIN chunks stay available, for what this +// loader doesn't interpret (animations, VRMC_springBone, ...), and so do the +// `extras` of the scene, nodes, meshes, 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. +// take the program down. A file that requires an extension this loader +// doesn't implement is an error too, as glTF asks, and so is one that would +// decode to more than Load's decode limit: a few bytes of JSON can point a +// thousand primitives or images at the same data. Running out of memory +// short of the limit isn't an error in the file; it throws std::bad_alloc, +// as it would anywhere else. export namespace Crafter { - struct GlbRGBA8 { - std::uint8_t r, g, b, a; - }; - struct GlbNode { std::string name; std::int32_t parent = -1; @@ -41,6 +43,9 @@ export namespace Crafter { std::int32_t camera = -1; // A light of KHR_lights_punctual: GlbAsset::lights. std::int32_t light = -1; + // Its mesh's morph target weights, for this node: one per target, or + // empty to keep the mesh's. + std::vector weights; // The node's extras as JSON text, or empty. std::string extras; }; @@ -84,27 +89,53 @@ export namespace Crafter { std::string extras; }; + // What a morph target adds to each vertex of its primitive, times the + // target's weight. Streams the target doesn't move stay empty. + struct GlbMorphTarget { + std::vector> positions; + std::vector> normals; + // Added to the tangents' xyz; the handedness in w stays. + std::vector> tangents; + }; + // One triangle list. Streams the primitive doesn't have stay empty; - // otherwise each has one entry per vertex. + // otherwise each has one entry per vertex. Strips and fans come back as + // lists. Points and lines aren't read: their primitives come back with no + // vertices, so the rest keep the file's numbering. struct GlbPrimitive { std::vector> positions; std::vector> normals; - // xyz plus the bitangent's handedness in w. Computed from the UVs when - // the file has none but has normals and TEXCOORD_0. + // xyz plus the bitangent's handedness in w. Where the file has none + // and the primitive has normals, computed from the UV set its + // material's normal texture samples (TEXCOORD_0 without one), as glTF + // asks. std::vector> tangents; std::vector> uv0; + // TEXCOORD_1: a second UV set, for the textures whose GlbTextureRef + // says texCoord 1. + std::vector> uv1; + // Up to four joints a vertex, their weights summing to 1. Where the + // file gives more (JOINTS_1 and WEIGHTS_1, ...), the four heaviest, + // a joint named twice counted once; where it gives a vertex no weight + // at all, all of it on the vertex's first joint, as three.js does. A + // slot left over holds joint 0 with weight 0. std::vector> joints; - // Normalized to sum to 1, which the file is meant to do and often - // only nearly does. std::vector> weights; // 0..n-1 when the primitive isn't indexed. std::vector indices; + std::vector targets; std::int32_t material = -1; }; struct GlbMesh { std::string name; std::vector primitives; + // The weights of the morph targets its primitives share, one per + // target; 0 where the file gives none. A node can override them. + std::vector weights; + // The mesh's extras as JSON text, or empty. Blender and three.js + // write the morph targets' names there, as targetNames. + std::string extras; }; struct GlbSkin { @@ -124,6 +155,8 @@ export namespace Crafter { struct GlbTextureRef { std::int32_t texture = -1; + // The UV set the texture is sampled with: 0 for uv0, 1 for uv1. Every + // primitive whose material samples a set has it. std::uint32_t texCoord = 0; // normalTexture's scale or occlusionTexture's strength. float scale = 1.0f; @@ -155,7 +188,9 @@ export namespace Crafter { std::string extras; }; - // The OpenGL filter and wrap enums, as in the file; 0 when unspecified. + // The OpenGL filter and wrap enums, as in the file. The filters are 0 + // when the file leaves them to the renderer; the wrap modes default to + // REPEAT, 10497, as glTF's do. struct GlbSampler { std::uint32_t magFilter = 0; std::uint32_t minFilter = 0; @@ -177,15 +212,25 @@ export namespace Crafter { std::vector materials; std::vector textures; std::vector samplers; - std::vector> images; + // One per image in the file, in RGBA8. An image in a format other + // than PNG and JPEG (the WebP or KTX2 of EXT_texture_webp or + // KHR_texture_basisu) isn't decoded and stays empty, sizeX 0; no + // texture's image is one. + std::vector>> images; std::vector cameras; std::vector lights; // The default scene's extras as JSON text, or empty. std::string extras; // The JSON chunk, verbatim. std::string json; + // The BIN chunk, verbatim: the data of what the JSON describes and + // this loader doesn't read (animations, ...). + std::vector bin; - static std::expected Load(const fs::path& path); - static std::expected Load(std::span glb); + // What Load decodes (vertices, indices and pixels) is held to + // `decodeLimit` bytes; without one, to 1 GiB plus 64 bytes for each + // byte of the file. + static std::expected Load(const fs::path& path, std::optional decodeLimit = std::nullopt); + static std::expected Load(std::span glb, std::optional decodeLimit = std::nullopt); }; } diff --git a/interfaces/Crafter.Asset-Texture.cppm b/interfaces/Crafter.Asset-Texture.cppm index 97744cd..56e4d55 100644 --- a/interfaces/Crafter.Asset-Texture.cppm +++ b/interfaces/Crafter.Asset-Texture.cppm @@ -6,6 +6,12 @@ module; #include "../lib/stb_image.h" #define STB_IMAGE_RESIZE_IMPLEMENTATION #include "../lib/stb_image_resize2.h" +// stb keeps the reason for its last failure until the next one, and a few +// of its failures give none: cleared before each decode, the reason after a +// failure is that failure's or null, never an older one's. +void CrafterAssetClearStbiFailure() { + stbi__g_failure_reason = nullptr; +} export module Crafter.Asset:Texture; import :Compression; import std; @@ -143,51 +149,110 @@ export namespace Crafter { file.read(reinterpret_cast(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. + // Decodes anything stb_image reads (PNG, JPEG, TGA, BMP, ...) to 8-bit + // RGBA, or to normalized floats when TT is a floating-point component + // type. A file that doesn't decode throws std::runtime_error, which + // callers such as crafter-build catch to report the file; built + // without exceptions (wasm), there is nothing to catch, and it is + // Fatal. template static TextureAsset LoadPNG(fs::path path) { std::filesystem::path abs = std::filesystem::absolute(path); - int sizeX; - int sizeY; - - 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())); + // Read whole and decoded from memory, so that the size checked + // before decoding is the size of the bytes that are decoded. + std::error_code error; + if (!fs::is_regular_file(abs, error)) { + Fail(std::format("LoadPNG: {}: {}", abs.string(), error ? error.message() : "not a file")); } - 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)); + const std::uintmax_t size = fs::file_size(abs, error); + if (error) Fail(std::format("LoadPNG: {}: {}", abs.string(), error.message())); + if (size > kMaxEncoded) Fail(std::format("LoadPNG: {}: {} bytes is more than stb_image reads", abs.string(), size)); + std::vector encoded(static_cast(size)); + std::ifstream file(abs, std::ios::binary); + if (!file.read(reinterpret_cast(encoded.data()), static_cast(encoded.size()))) { + Fail(std::format("LoadPNG: {}: can't read it", abs.string())); } - return FromRGBA8(data, sizeX, sizeY); + std::expected, std::string> tex = LoadPNGFromMemory(encoded); + if (!tex) Fail(std::format("LoadPNG: {}: {}", abs.string(), tex.error())); + return std::move(*tex); } // 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. + // rather than an exception or a Fatal: the caller is reading a larger + // asset and can say which part was bad. One too large for a texture + // is turned down from its header, before anything is decoded. template static std::expected, std::string> LoadPNGFromMemory(std::span encoded) { + const auto size = SizeFromMemory(encoded); + if (!size) return std::unexpected(size.error()); + int sizeX; int sizeY; + CrafterAssetClearStbiFailure(); unsigned char* data = stbi_load_from_memory(reinterpret_cast(encoded.data()), static_cast(encoded.size()), &sizeX, &sizeY, nullptr, 4); if (data == nullptr) { - return std::unexpected(std::string(stbi_failure_reason())); + return std::unexpected(FailureReason()); } 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 std::unexpected(std::format("{}x{} decoded from a header of {}x{}", sizeX, sizeY, size->first, size->second)); } return FromRGBA8(data, sizeX, sizeY); } + // The width and height LoadPNGFromMemory would decode an image to, + // read from its header alone, or why it wouldn't: an image it can't + // read, or one too large for a texture. + static std::expected, std::string> SizeFromMemory(std::span encoded) { + if (encoded.size() > kMaxEncoded) { + return std::unexpected(std::format("{} bytes is more than stb_image reads", encoded.size())); + } + int sizeX; + int sizeY; + int components; + CrafterAssetClearStbiFailure(); + if (!stbi_info_from_memory(reinterpret_cast(encoded.data()), static_cast(encoded.size()), + &sizeX, &sizeY, &components)) { + return std::unexpected(FailureReason()); + } + if (!FitsSize(sizeX, sizeY)) { + return std::unexpected(std::format("{}x{} is more than a texture takes: 65535 a side, {} pixels in all", sizeX, sizeY, kMaxPixels)); + } + return std::pair(sizeX, sizeY); + } + private: - // sizeX and sizeY hold 16 bits. - static bool FitsSize(int sizeX, int sizeY) { - return sizeX <= std::numeric_limits::max() && sizeY <= std::numeric_limits::max(); + // stb_image takes an encoded image's length as an int. + static constexpr std::uintmax_t kMaxEncoded = std::numeric_limits::max(); + // sizeX and sizeY hold 16 bits, and stb_image's arithmetic 32: its + // 16-bit PNG decoding sizes a buffer of 8 bytes a pixel in an + // unsigned int, which wraps round from 2^29 pixels. 2^28 is + // 16384x16384, 1 GiB of RGBA8. + static constexpr std::uint64_t kMaxPixels = std::uint64_t{1} << 28; + + static bool FitsSize(std::uint64_t sizeX, std::uint64_t sizeY) { + return sizeX <= std::numeric_limits::max() && sizeY <= std::numeric_limits::max() + && sizeX * sizeY <= kMaxPixels; + } + + // stb_image's reason for the failure just now. CrafterAssetClearStbiFailure + // before the call makes it this failure's, but some failures give + // none. + static std::string FailureReason() { + const char* reason = stbi_failure_reason(); + return reason != nullptr ? reason : "corrupt image"; + } + + [[noreturn]] static void Fail(const std::string& message) { +#if __cpp_exceptions + throw std::runtime_error(message); +#else + Compression::Fatal(message); +#endif } // Converts stb's RGBA8 buffer, classifies its opacity and frees it. diff --git a/tests/GLB/main.cpp b/tests/GLB/main.cpp index 33e9c1d..453b5a8 100644 --- a/tests/GLB/main.cpp +++ b/tests/GLB/main.cpp @@ -1,20 +1,27 @@ //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 +// GlbAsset::Load on GLBs built here in memory. A fixture: 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. +// accessors, a second UV set, 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. Morph targets, one of them +// sparse; strips and fans made lists, points and lines passed over; tangents +// from the UV set the normal map samples; the four heaviest of eight joints +// a vertex; images in formats only an extension reads; and the decode limit, +// which a file repeating the same data runs into. +// +// Then the ways a file can be broken, which must come back as errors rather +// than crash or load wrong: bad magic, truncation, an accessor past its view, +// references that don't exist or that wrap round 32 bits, a stride or count +// whose arithmetic wraps round to look in bounds, a chunk longer than the +// file, a path that isn't a file, an extension the loader doesn't implement +// that the file requires, an image too large for a texture or one stb fails +// to decode without saying why, extras nested 200,000 deep, and children that +// form a cycle. A chain 100,000 nodes deep loads in time in proportion to its +// depth: the cycle check once 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 @@ -30,6 +37,8 @@ using namespace Crafter; namespace { +using Pixel = Vector; + int failures = 0; void Check(bool ok, std::string_view what) { @@ -41,6 +50,19 @@ bool Near(float a, float b, float epsilon = 1e-4f) { return std::abs(a - b) < epsilon; } +// That `loaded` came back as an error with `text` in it. +template +void CheckError(const std::expected& loaded, std::string_view text, std::string_view what) { + const bool ok = !loaded && loaded.error().find(text) != std::string::npos; + Check(ok, ok ? std::string(what) : loaded ? std::format("{} (it loaded)", what) : std::format("{} (the error: {})", what, loaded.error())); +} + +// That `loaded` loaded, and if not, why. +bool CheckLoads(const std::expected& loaded, std::string_view what) { + Check(loaded.has_value(), loaded ? std::string(what) : std::format("{}: {}", what, loaded.error())); + return loaded.has_value(); +} + // A 2x2 RGBA PNG: red, green, blue, and white with alpha 0. constexpr std::array kPng = { 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, @@ -50,6 +72,13 @@ constexpr std::array kPng = { 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, }; +// A 1x1 GIF: white. +constexpr std::array kGif = { + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, 0xff, 0xff, 0xff, + 0x00, 0x00, 0x00, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02, 0x44, + 0x01, 0x00, 0x3b, +}; + struct Writer { std::vector bytes; @@ -85,6 +114,38 @@ std::vector BuildGlb(std::string_view json, const std::vector LoadJson(std::string_view json, const std::vector& bin, + std::optional decodeLimit = std::nullopt) { + return GlbAsset::Load(BuildGlb(json, bin), decodeLimit); +} + +// `json` with each `from` replaced by its `to`. A `from` that isn't there is +// a mistake in the test, and fails it. +std::string Replace(std::string json, std::initializer_list> changes) { + for (const auto& [from, to] : changes) { + const std::size_t at = json.find(from); + if (at == std::string::npos) { + Check(false, std::format("the test's own edit: '{}' is in the JSON", from)); + continue; + } + json.replace(at, from.size(), to); + } + return json; +} + +// A file in the temp directory whose name is this run's own, as the test may +// run alongside itself. +std::filesystem::path TempFile(std::string_view name) { + static const unsigned run = std::random_device{}(); + return std::filesystem::temp_directory_path() / std::format("crafter-asset-glb-test-{}-{}", run, name); +} + +std::vector Bytes(std::span bytes) { + std::vector out(bytes.size()); + std::memcpy(out.data(), bytes.data(), bytes.size()); + return out; +} + std::vector FixtureBin() { Writer w; // bufferView 0, offset 0: interleaved position + normal, stride 24. @@ -109,6 +170,8 @@ std::vector FixtureBin() { 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); + // bufferView 8, offset 352: TEXCOORD_1 as floats. + for (float f : {0.25f, 0.5f, 0.75f, 0.5f, 0.25f, 1.0f}) w.Put(f); return w.bytes; } @@ -123,8 +186,8 @@ std::string FixtureJson(std::string_view accessor6Extra = "", std::uint32_t acce {{"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}} + {{"attributes": {{"POSITION": 0, "NORMAL": 1, "TEXCOORD_0": 2, "TEXCOORD_1": 8, "JOINTS_0": 4, "WEIGHTS_0": 5}}, "indices": 3, "material": 0}}, + {{"attributes": {{"POSITION": 6, "JOINTS_0": 4, "WEIGHTS_0": 5}}, "material": 1}} ]}}], "skins": [{{"name": "Skin", "joints": [0, 1], "inverseBindMatrices": 7}}], "materials": [ @@ -144,7 +207,8 @@ std::string FixtureJson(std::string_view accessor6Extra = "", std::uint32_t acce {{"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"}} + {{"bufferView": 6, "componentType": 5126, "count": {}, "type": "MAT4"}}, + {{"bufferView": 8, "componentType": 5126, "count": 3, "type": "VEC2"}} ], "bufferViews": [ {{"buffer": 0, "byteOffset": 0, "byteLength": 72, "byteStride": 24}}, @@ -154,9 +218,10 @@ std::string FixtureJson(std::string_view accessor6Extra = "", std::uint32_t acce {{"buffer": 0, "byteOffset": 100, "byteLength": 12}}, {{"buffer": 0, "byteOffset": 112, "byteLength": 36}}, {{"buffer": 0, "byteOffset": 148, "byteLength": 128}}, - {{"buffer": 0, "byteOffset": 276, "byteLength": 76}} + {{"buffer": 0, "byteOffset": 276, "byteLength": 76}}, + {{"buffer": 0, "byteOffset": 352, "byteLength": 24}} ], - "buffers": [{{"byteLength": 352}}], + "buffers": [{{"byteLength": 376}}], "extensionsUsed": ["VRMC_springBone"], "extensions": {{"VRMC_springBone": {{"specVersion": "1.0"}}}} }})", accessor6Extra, accessor7Count); @@ -173,8 +238,7 @@ bool NearMatrix(const MatrixRowMajor& m, std::array e void TestFixture() { const std::vector glb = BuildGlb(FixtureJson(), FixtureBin()); const std::expected loaded = GlbAsset::Load(glb); - Check(loaded.has_value(), loaded.has_value() ? "the fixture loads" : std::format("the fixture loads: {}", loaded.error())); - if (!loaded) return; + if (!CheckLoads(loaded, "the fixture loads")) return; const GlbAsset& a = *loaded; // Nodes and scene. @@ -200,6 +264,8 @@ void TestFixture() { 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.uv1.size() == 3 && Near(p0.uv1[0].x, 0.25f) && Near(p0.uv1[1].x, 0.75f) && Near(p0.uv1[2].y, 1), + "a second UV set, TEXCOORD_1"); Check(p0.indices == std::vector{0, 1, 2}, "u8 indices widened"); Check(p0.joints.size() == 3 && p0.joints[0] == std::array{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), @@ -212,7 +278,9 @@ void TestFixture() { Check(p0.material == 0 && p1.material == 1, "primitive materials"); Check(p1.positions.size() == 3 && Near(p1.positions[2].z, 1) && p1.indices == std::vector{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"); + Check(p1.normals.empty() && p1.tangents.empty() && p1.uv0.empty() && p1.uv1.empty(), "absent streams stay empty"); + Check(p0.targets.empty() && a.meshes[0].weights.empty() && a.nodes[0].weights.empty() && a.meshes[0].extras.empty(), + "no morph targets, no weights for them, and no extras"); // Skin. Check(a.skins.size() == 1 && a.skins[0].joints == std::vector{0, 1} @@ -234,16 +302,18 @@ void TestFixture() { "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.samplers.size() == 1 && a.samplers[0].magFilter == 9728 && a.samplers[0].minFilter == 9729 && a.samplers[0].wrapS == 10497, + "sampler filters, and the wrap mode's default"); 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]; + const Pixel 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"); + Check(a.bin == FixtureBin(), "the BIN chunk is kept, for the data the loader doesn't read"); } // What a room exported from Blender carries beyond meshes: a camera, @@ -282,14 +352,13 @@ std::string RoomJson(std::string_view candleLight = "0") { "textures": [{{"source": 0}}], "images": [{{"bufferView": 0, "mimeType": "image/png"}}], "bufferViews": [{{"buffer": 0, "byteOffset": 276, "byteLength": 76}}], - "buffers": [{{"byteLength": 352}}] + "buffers": [{{"byteLength": 376}}] }})", candleLight); } void TestRoom() { const std::expected 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; + if (!CheckLoads(loaded, "the room fixture loads")) return; const GlbAsset& a = *loaded; Check(a.cameras.size() == 1 && a.nodes[0].camera == 0 && a.nodes[1].camera == -1, "a node's camera"); @@ -337,12 +406,356 @@ void TestRoom() { 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"); + CheckError(GlbAsset::Load(BuildGlb(RoomJson("7"), FixtureBin())), "light", "a node naming a light that doesn't exist is an error"); + + const auto required = LoadJson(Replace(RoomJson(), {{R"("extensionsUsed")", R"("extensionsRequired": ["KHR_lights_punctual"], "extensionsUsed")"}}), + FixtureBin()); + Check(required && required->lights.size() == 3, "a file that requires KHR_lights_punctual, which the loader implements, loads"); + const auto nullExtras = LoadJson(Replace(RoomJson(), {{R"("extras": {"seat": true, "height": 1.6})", R"("extras": null)"}}), FixtureBin()); + Check(nullExtras && nullExtras->nodes[4].extras.empty(), "extras of null are no extras"); } -// 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. +// Three vertices and two morph targets: one moves the positions and normals, +// and one, sparse over no bufferView, moves vertex 2 alone. +std::vector MorphBin() { + Writer w; + // bufferView 0, offset 0: positions. + for (float f : {0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f}) w.Put(f); + // bufferView 1, offset 36: target 0's position offsets. + for (float f : {0.0f, 0.0f, 0.5f, 0.0f, 0.0f, 0.25f, 0.0f, 0.0f, 0.125f}) w.Put(f); + // bufferView 2, offset 72: normals along +Z, and target 0's normal offsets. + for (int v = 0; v < 3; ++v) for (float f : {0.0f, 0.0f, 1.0f}) w.Put(f); + // bufferView 3, offset 108: target 1's sparse index, u8. + w.Put(2); + w.Pad(4, std::byte{0}); + // bufferView 4, offset 112: target 1's sparse value. + for (float f : {0.0f, 2.0f, 0.0f}) w.Put(f); + return w.bytes; +} + +std::string MorphJson() { + return R"({ + "asset": {"version": "2.0"}, + "nodes": [{"mesh": 0, "weights": [1, 0.5]}, {"mesh": 0}], + "meshes": [{"name": "Face", "weights": [0.25, 0.75], "extras": {"targetNames": ["Smile", "Blink"]}, + "primitives": [{"attributes": {"POSITION": 0, "NORMAL": 2}, "targets": [{"POSITION": 1, "NORMAL": 2}, {"POSITION": 3}]}]}], + "accessors": [ + {"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3"}, + {"bufferView": 1, "componentType": 5126, "count": 3, "type": "VEC3"}, + {"bufferView": 2, "componentType": 5126, "count": 3, "type": "VEC3"}, + {"componentType": 5126, "count": 3, "type": "VEC3", + "sparse": {"count": 1, "indices": {"bufferView": 3, "componentType": 5121}, "values": {"bufferView": 4}}} + ], + "bufferViews": [ + {"buffer": 0, "byteOffset": 0, "byteLength": 36}, + {"buffer": 0, "byteOffset": 36, "byteLength": 36}, + {"buffer": 0, "byteOffset": 72, "byteLength": 36}, + {"buffer": 0, "byteOffset": 108, "byteLength": 1}, + {"buffer": 0, "byteOffset": 112, "byteLength": 12} + ], + "buffers": [{"byteLength": 124}] +})"; +} + +void TestMorphTargets() { + const auto loaded = LoadJson(MorphJson(), MorphBin()); + if (!CheckLoads(loaded, "a mesh with morph targets loads")) return; + const GlbMesh& mesh = loaded->meshes[0]; + const GlbPrimitive& p = mesh.primitives[0]; + Check(p.targets.size() == 2, "two morph targets"); + if (p.targets.size() != 2) return; + const GlbMorphTarget& smile = p.targets[0]; + Check(smile.positions.size() == 3 && Near(smile.positions[1].z, 0.25f) && Near(smile.positions[2].z, 0.125f) + && smile.normals.size() == 3 && Near(smile.normals[0].z, 1) && smile.tangents.empty(), + "a target's position and normal offsets"); + const GlbMorphTarget& blink = p.targets[1]; + Check(blink.positions.size() == 3 && Near(blink.positions[0].y, 0) && Near(blink.positions[1].y, 0) && Near(blink.positions[2].y, 2) + && blink.normals.empty(), + "a sparse target over no bufferView: zeros, and the one value it gives"); + Check(mesh.weights == std::vector{0.25f, 0.75f}, "the mesh's morph weights"); + Check(loaded->nodes[0].weights == std::vector{1, 0.5f} && loaded->nodes[1].weights.empty(), "a node's own morph weights"); + Check(mesh.extras == R"({"targetNames":["Smile","Blink"]})", "the mesh's extras, with the targets' names"); + + CheckError(LoadJson(Replace(MorphJson(), {{R"("weights": [1, 0.5])", R"("weights": [1])"}}), MorphBin()), "morph weights", + "a node with a morph weight short is an error"); + CheckError(LoadJson(Replace(MorphJson(), {{R"("weights": [0.25, 0.75])", R"("weights": [0.25])"}}), MorphBin()), "morph weights", + "a mesh with a morph weight short is an error"); + CheckError(LoadJson(Replace(MorphJson(), {{R"({"POSITION": 3}]})", R"({"POSITION": 3}]}, {"attributes": {"POSITION": 0}})"}}), MorphBin()), + "same number of morph targets", "primitives of a mesh with different morph targets are an error"); + CheckError(LoadJson(Replace(MorphJson(), {{R"("count": 3, "type": "VEC3",)", R"("count": 2, "type": "VEC3",)"}}), MorphBin()), "sparse", + "a sparse index past its accessor is an error"); +} + +// The four corners of a square. +std::vector QuadBin() { + Writer w; + for (float f : {0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 1.0f, 0.0f}) w.Put(f); + return w.bytes; +} + +std::string ModesJson() { + return R"({ + "asset": {"version": "2.0"}, + "meshes": [{"primitives": [ + {"attributes": {"POSITION": 0}, "mode": 5}, + {"attributes": {"POSITION": 0}, "mode": 6}, + {"attributes": {"POSITION": 0}, "mode": 1, "material": 0}, + {"attributes": {"POSITION": 0}, "mode": 0} + ]}], + "materials": [{"name": "Wire"}], + "accessors": [{"bufferView": 0, "componentType": 5126, "count": 4, "type": "VEC3"}], + "bufferViews": [{"buffer": 0, "byteOffset": 0, "byteLength": 48}], + "buffers": [{"byteLength": 48}] +})"; +} + +void TestPrimitiveModes() { + const auto loaded = LoadJson(ModesJson(), QuadBin()); + if (!CheckLoads(loaded, "a mesh of strips, fans, lines and points loads")) return; + const std::vector& p = loaded->meshes[0].primitives; + Check(p.size() == 4, "every primitive is kept, so they keep the file's numbering"); + if (p.size() != 4) return; + // glTF's numbering: a strip's triangle i is (i, i+1, i+2), its last two + // swapped when i is odd; a fan's is (i+1, i+2, 0). + Check(p[0].indices == std::vector{0, 1, 2, 1, 3, 2} && p[0].positions.size() == 4, "a triangle strip as a list"); + Check(p[1].indices == std::vector{1, 2, 0, 2, 3, 0}, "a triangle fan as a list"); + Check(p[2].positions.empty() && p[2].indices.empty() && p[2].material == 0 && p[3].positions.empty(), + "lines and points come back with no vertices"); + CheckError(LoadJson(Replace(ModesJson(), {{R"("mode": 5)", R"("mode": 7)"}}), QuadBin()), "mode 7", "a mode glTF doesn't define is an error"); +} + +// A triangle whose TEXCOORD_0 runs u along +X and TEXCOORD_1 runs it along +// +Y, normal-mapped through TEXCOORD_1. +std::vector TangentBin() { + Writer w; + // bufferView 0, offset 0: a triangle in the XY plane. + for (float f : {0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f}) w.Put(f); + // bufferView 1, offset 36: normals along +Z. + for (int v = 0; v < 3; ++v) for (float f : {0.0f, 0.0f, 1.0f}) w.Put(f); + // bufferView 2, offset 72: TEXCOORD_0, u along +X and v along +Y. + for (float f : {0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f}) w.Put(f); + // bufferView 3, offset 96: TEXCOORD_1, u along +Y and v along +X. + for (float f : {0.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f}) w.Put(f); + // bufferView 4, offset 120: normals of zero length. + for (int i = 0; i < 9; ++i) w.Put(0.0f); + // bufferView 5, offset 156: UVs all at one point. + for (int i = 0; i < 6; ++i) w.Put(0.0f); + // bufferView 6, offset 180: the PNG. + for (std::uint8_t b : kPng) w.Put(b); + return w.bytes; +} + +std::string TangentJson() { + return R"({ + "asset": {"version": "2.0"}, + "meshes": [{"primitives": [ + {"attributes": {"POSITION": 0, "NORMAL": 1, "TEXCOORD_0": 2, "TEXCOORD_1": 3}, "material": 0}, + {"attributes": {"POSITION": 0, "NORMAL": 1, "TEXCOORD_1": 3}, "material": 0}, + {"attributes": {"POSITION": 0, "NORMAL": 4, "TEXCOORD_0": 5}} + ]}], + "materials": [{"name": "Bumpy", "normalTexture": {"index": 0, "texCoord": 1}}], + "textures": [{"source": 0}], + "images": [{"bufferView": 6, "mimeType": "image/png"}], + "accessors": [ + {"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3"}, + {"bufferView": 1, "componentType": 5126, "count": 3, "type": "VEC3"}, + {"bufferView": 2, "componentType": 5126, "count": 3, "type": "VEC2"}, + {"bufferView": 3, "componentType": 5126, "count": 3, "type": "VEC2"}, + {"bufferView": 4, "componentType": 5126, "count": 3, "type": "VEC3"}, + {"bufferView": 5, "componentType": 5126, "count": 3, "type": "VEC2"} + ], + "bufferViews": [ + {"buffer": 0, "byteOffset": 0, "byteLength": 36}, + {"buffer": 0, "byteOffset": 36, "byteLength": 36}, + {"buffer": 0, "byteOffset": 72, "byteLength": 24}, + {"buffer": 0, "byteOffset": 96, "byteLength": 24}, + {"buffer": 0, "byteOffset": 120, "byteLength": 36}, + {"buffer": 0, "byteOffset": 156, "byteLength": 24}, + {"buffer": 0, "byteOffset": 180, "byteLength": 76} + ], + "buffers": [{"byteLength": 256}] +})"; +} + +void TestTangents() { + const auto loaded = LoadJson(TangentJson(), TangentBin()); + if (!CheckLoads(loaded, "normal-mapped primitives with two UV sets load")) return; + const std::vector& p = loaded->meshes[0].primitives; + // Along +Y, uv1's u; uv1's v runs along +X, so the bitangent, along -v, + // is -X: cross(normal, tangent) * +1. + auto alongY = [](const GlbPrimitive& primitive) { + return primitive.tangents.size() == 3 && Near(primitive.tangents[0].x, 0) && Near(primitive.tangents[0].y, 1) + && Near(primitive.tangents[0].z, 0) && Near(primitive.tangents[0].w, 1); + }; + Check(alongY(p[0]), "tangents from the UV set the normal texture samples, TEXCOORD_1, not TEXCOORD_0"); + Check(alongY(p[1]), "tangents for a primitive with TEXCOORD_1 alone"); + Check(p[2].tangents.size() == 3 && std::ranges::all_of(p[2].tangents, [](const Vector& t) { + return std::isfinite(t.x) && std::isfinite(t.y) && std::isfinite(t.z) && std::isfinite(t.w) && Near(t.x * t.x + t.y * t.y + t.z * t.z, 1); + }), + "a zero normal with no UV gradient still gets a unit tangent, not NaN"); +} + +// A triangle skinned through JOINTS_0/WEIGHTS_0 and JOINTS_1/WEIGHTS_1: +// vertex 0 names joint 1 in both, vertex 1 has eight influences, and vertex +// 2 no weight at all. +std::vector SkinBin() { + Writer w; + // bufferView 0, offset 0: a triangle. + for (float f : {0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f}) w.Put(f); + // bufferView 1, offset 36: JOINTS_0 as u8. + for (std::uint8_t j : {0, 1, 2, 3, 0, 1, 2, 3, 5, 1, 2, 3}) w.Put(j); + // bufferView 2, offset 48: WEIGHTS_0 as floats. + for (float f : {0.3f, 0.2f, 0.0f, 0.0f, 0.1f, 0.2f, 0.05f, 0.15f, 0.0f, 0.0f, 0.0f, 0.0f}) w.Put(f); + // bufferView 3, offset 96: JOINTS_1. + for (std::uint8_t j : {1, 4, 5, 6, 4, 5, 6, 7, 0, 1, 2, 3}) w.Put(j); + // bufferView 4, offset 108: WEIGHTS_1. + for (float f : {0.5f, 0.0f, 0.0f, 0.0f, 0.25f, 0.1f, 0.1f, 0.05f, 0.0f, 0.0f, 0.0f, 0.0f}) w.Put(f); + return w.bytes; +} + +std::string SkinJson() { + return R"({ + "asset": {"version": "2.0"}, + "nodes": [{"name": "Body", "mesh": 0, "skin": 0}, {}, {}, {}, {}, {}, {}, {}], + "meshes": [{"name": "Body", "primitives": [{"attributes": {"POSITION": 0, "JOINTS_0": 1, "WEIGHTS_0": 2, "JOINTS_1": 3, "WEIGHTS_1": 4}}]}], + "skins": [{"name": "Rig", "joints": [0, 1, 2, 3, 4, 5, 6, 7]}], + "accessors": [ + {"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3"}, + {"bufferView": 1, "componentType": 5121, "count": 3, "type": "VEC4"}, + {"bufferView": 2, "componentType": 5126, "count": 3, "type": "VEC4"}, + {"bufferView": 3, "componentType": 5121, "count": 3, "type": "VEC4"}, + {"bufferView": 4, "componentType": 5126, "count": 3, "type": "VEC4"} + ], + "bufferViews": [ + {"buffer": 0, "byteOffset": 0, "byteLength": 36}, + {"buffer": 0, "byteOffset": 36, "byteLength": 12}, + {"buffer": 0, "byteOffset": 48, "byteLength": 48}, + {"buffer": 0, "byteOffset": 96, "byteLength": 12}, + {"buffer": 0, "byteOffset": 108, "byteLength": 48} + ], + "buffers": [{"byteLength": 156}] +})"; +} + +void TestSkinning() { + const auto loaded = LoadJson(SkinJson(), SkinBin()); + if (!CheckLoads(loaded, "a mesh skinned through two sets of joints loads")) return; + const GlbPrimitive& p = loaded->meshes[0].primitives[0]; + Check(p.joints.size() == 3 && p.weights.size() == 3, "joints and weights for every vertex"); + if (p.joints.size() != 3 || p.weights.size() != 3) return; + Check(p.joints[0] == std::array{0, 1, 0, 0} && Near(p.weights[0][0], 0.3f) && Near(p.weights[0][1], 0.7f) + && Near(p.weights[0][2], 0) && Near(p.weights[0][3], 0), + "a joint in both sets counted once, its weights added: 0.3 and 0.7"); + Check(p.joints[1] == std::array{4, 1, 3, 0} && Near(p.weights[1][0], 0.25f / 0.7f) && Near(p.weights[1][1], 0.2f / 0.7f) + && Near(p.weights[1][2], 0.15f / 0.7f) && Near(p.weights[1][3], 0.1f / 0.7f), + "of eight influences the four heaviest, their weights summing to 1"); + Check(p.joints[2] == std::array{5, 0, 0, 0} && p.weights[2] == std::array{1, 0, 0, 0}, + "a vertex with no weight is bound wholly to its first joint"); + + CheckError(LoadJson(Replace(SkinJson(), {{R"("JOINTS_0": 1, "WEIGHTS_0": 2, "JOINTS_1": 3, "WEIGHTS_1": 4)", R"("JOINTS_0": 1)"}}), SkinBin()), + "pair up", "JOINTS_0 without WEIGHTS_0 is an error"); + CheckError(LoadJson(Replace(SkinJson(), {{R"(, "WEIGHTS_1": 4)", ""}}), SkinBin()), "pair up", "JOINTS_1 without WEIGHTS_1 is an error"); + CheckError(LoadJson(Replace(SkinJson(), {{R"("WEIGHTS_1": 4}})", R"("WEIGHTS_1": 4}}, {"attributes": {"POSITION": 0}})"}}), SkinBin()), + "without JOINTS_0", "a skinned node whose mesh has a primitive without joints is an error"); + CheckError(LoadJson(Replace(SkinJson(), {{R"({"name": "Body", "mesh": 0, "skin": 0})", R"({"name": "Body", "skin": 0})"}}), SkinBin()), + "no mesh", "a skin on a node without a mesh is an error"); + CheckError(LoadJson(Replace(SkinJson(), {{R"("joints": [0, 1, 2, 3, 4, 5, 6, 7])", R"("joints": [0, 1, 2, 3])"}}), SkinBin()), + "beyond its skin's 4 joints", "a joint past the skin's joints is an error"); +} + +// References that wrap round when narrowed to 32 bits, whole numbers written +// as JSON floats, UV sets, scenes, and what a file may require. +void TestReferences() { + auto fixture = [](std::initializer_list> changes) { + return LoadJson(Replace(FixtureJson(), changes), FixtureBin()); + }; + CheckError(fixture({{R"("children": [1])", R"("children": [4294967297])"}}), "child", "a child 2^32 + 1 is an error, not node 1"); + CheckError(fixture({{R"("mesh": 0, "skin": 0)", R"("mesh": 4294967296, "skin": 0)"}}), "mesh", "a mesh 2^32 is an error, not mesh 0"); + CheckError(fixture({{R"("mesh": 0, "skin": 0)", R"("mesh": -1, "skin": 0)"}}), "mesh", "a mesh of -1 is an error, not no mesh"); + CheckError(fixture({{R"("textures": [{"source": 0,)", R"("textures": [{"source": 4294967296,)"}}), "image", + "a texture's image 2^32 is an error, not image 0"); + CheckError(fixture({{R"({"bufferView": 0, "byteOffset": 0, "componentType": 5126,)", R"({"bufferView": 0, "byteOffset": 0, "componentType": 4294972422,)"}}), + "componentType", "a componentType of FLOAT + 2^32 is an error, not FLOAT"); + CheckError(LoadJson(Replace(RoomJson(), {{R"("camera": 0)", R"("camera": 4294967295)"}}), FixtureBin()), "camera", + "a camera 2^32 - 1 is an error, not no camera"); + CheckError(fixture({{R"("magFilter": 9728)", R"("magFilter": 4294977024)"}}), "sampler", "a magFilter of NEAREST + 2^32 is an error"); + + const auto floats = fixture({{R"("byteStride": 24)", R"("byteStride": 24.0)"}, + {R"("mesh": 0, "skin": 0)", R"("mesh": 0.0, "skin": 0)"}, + {R"("componentType": 5126, "count": 3, "type": "VEC3"})", R"("componentType": 5126, "count": 3e0, "type": "VEC3"})"}}); + Check(floats && floats->nodes[0].mesh == 0 && Near(floats->meshes[0].primitives[0].positions[1].x, 1) + && Near(floats->meshes[0].primitives[0].normals[2].z, 1), + floats ? "whole numbers written as 24.0 or 3e0 read as integers" : std::format("whole numbers written as 24.0 or 3e0 read as integers: {}", floats.error())); + + const auto uv1 = fixture({{R"("baseColorTexture": {"index": 0})", R"("baseColorTexture": {"index": 0, "texCoord": 1})"}}); + Check(uv1 && uv1->materials[0].baseColorTexture.texCoord == 1, "a texture sampled with TEXCOORD_1"); + CheckError(fixture({{R"("baseColorTexture": {"index": 0})", R"("baseColorTexture": {"index": 0, "texCoord": 1})"}, {R"("TEXCOORD_1": 8, )", ""}}), + "TEXCOORD_1", "a texture sampled with TEXCOORD_1 on a primitive without it is an error"); + CheckError(fixture({{R"("baseColorTexture": {"index": 0})", R"("baseColorTexture": {"index": 0, "texCoord": 2})"}}), "UV set", + "a texture sampled with TEXCOORD_2, which the loader doesn't read, is an error"); + CheckError(fixture({{R"("baseColorTexture": {"index": 0})", R"("baseColorTexture": {"index": 0, "texCoord": -1})"}}), "UV set", + "a texCoord of -1 is an error"); + + CheckError(fixture({{R"("scene": 0)", R"("scene": 5)"}}), "default scene", "a default scene that doesn't exist is an error"); + CheckError(fixture({{R"("nodes": [0]})", R"("nodes": [0, 1]})"}}), "a child", "a scene with a child node as a root is an error"); + CheckError(fixture({{R"("nodes": [0]})", R"("nodes": [0, 0]})"}}), "twice", "a scene naming a root twice is an error"); + + CheckError(fixture({{R"("extensionsUsed": ["VRMC_springBone"])", R"("extensionsUsed": ["KHR_draco_mesh_compression"], "extensionsRequired": ["KHR_draco_mesh_compression"])"}}), + "KHR_draco_mesh_compression", "a file that requires an extension the loader doesn't implement is an error"); +} + +// The fixture's BIN, followed by images in formats core glTF doesn't have: a +// WebP's start, as EXT_texture_webp adds, and a GIF. +std::vector OtherImagesBin() { + Writer w{FixtureBin()}; + // bufferView 9, offset 376. + for (char c : std::string_view("RIFF\x08\0\0\0WEBPVP8 ", 16)) w.Put(c); + // bufferView 10, offset 392. + for (std::uint8_t b : kGif) w.Put(b); + return w.bytes; +} + +std::string OtherImagesJson(std::string_view images, std::string_view textures) { + return Replace(FixtureJson(), { + {R"({"buffer": 0, "byteOffset": 352, "byteLength": 24})", + R"({"buffer": 0, "byteOffset": 352, "byteLength": 24}, {"buffer": 0, "byteOffset": 376, "byteLength": 16}, {"buffer": 0, "byteOffset": 392, "byteLength": 35})"}, + {R"("images": [{"bufferView": 7, "mimeType": "image/png"}])", images}, + {R"("textures": [{"source": 0, "sampler": 0}])", textures}, + {R"("extensionsUsed": ["VRMC_springBone"])", R"("extensionsUsed": ["VRMC_springBone", "EXT_texture_webp"])"}, + }); +} + +// A PNG of the given header and IDAT: big-endian chunks with their CRCs. +std::vector Png(std::uint32_t width, std::uint32_t height, std::uint8_t depth, std::uint8_t colorType, + const std::vector& idat) { + std::vector png = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'}; + auto be32 = [](std::vector& out, std::uint32_t v) { + for (int shift = 24; shift >= 0; shift -= 8) out.push_back(static_cast(v >> shift)); + }; + auto chunk = [&](const char* type, const std::vector& data) { + be32(png, static_cast(data.size())); + std::vector 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 header; + be32(header, width); + be32(header, height); + header.insert(header.end(), {depth, colorType, 0, 0, 0}); + chunk("IHDR", header); + chunk("IDAT", idat); + chunk("IEND", {}); + return Bytes(png); +} + +// A grey PNG `width` by 1, its row stored without compression: zlib's stored +// blocks with its Adler-32. std::vector WidePng(std::uint32_t width) { std::vector row(1 + width, 128); row[0] = 0; // no filter @@ -362,33 +775,99 @@ std::vector WidePng(std::uint32_t width) { a = (a + c) % 65521; b = (b + a) % 65521; } - std::vector png = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'}; - auto be32 = [](std::vector& out, std::uint32_t v) { - for (int shift = 24; shift >= 0; shift -= 8) out.push_back(static_cast(v >> shift)); + for (int shift = 24; shift >= 0; shift -= 8) zlib.push_back(static_cast(((b << 16) | a) >> shift)); + return Png(width, 1, 8, 0, zlib); +} + +void TestImages() { + using Image = TextureAsset; + + // Only an extension reads WebP; stb doesn't, so the file failed whole. + const auto webp = LoadJson(OtherImagesJson(R"("images": [{"bufferView": 7, "mimeType": "image/png"}, {"bufferView": 9, "mimeType": "image/webp"}])", + R"("textures": [{"source": 0, "sampler": 0, "extensions": {"EXT_texture_webp": {"source": 1}}}])"), + OtherImagesBin()); + Check(webp && webp->images.size() == 2 && webp->images[0].pixels.size() == 4 && webp->images[1].sizeX == 0 && webp->images[1].pixels.empty(), + webp ? "a WebP that EXT_texture_webp falls back from stays undecoded, and the file loads" : std::format("a WebP stays undecoded: {}", webp.error())); + CheckError(LoadJson(OtherImagesJson(R"("images": [{"bufferView": 7, "mimeType": "image/png"}, {"bufferView": 9, "mimeType": "image/webp"}])", + R"("textures": [{"source": 1, "sampler": 0}])"), + OtherImagesBin()), + "neither a PNG nor a JPEG", "a texture whose image is a WebP is an error"); + + // stb decodes this GIF; a GLB has no business with it. + const auto gif = Image::LoadPNGFromMemory(Bytes(kGif)); + Check(gif && gif->sizeX == 1, "the test's GIF decodes, as stb reads GIFs"); + const auto gifInGlb = LoadJson(OtherImagesJson(R"("images": [{"bufferView": 7, "mimeType": "image/png"}, {"bufferView": 10, "mimeType": "image/png"}])", + R"("textures": [{"source": 0, "sampler": 0}])"), + OtherImagesBin()); + Check(gifInGlb && gifInGlb->images[1].pixels.empty(), "a GIF in a GLB, even tagged image/png, isn't decoded"); + + // Two images of the same bufferView: decoded once, copied once, and the + // copy is paid for out of the decode limit. + auto twice = [](std::optional limit) { + return LoadJson(R"({"asset": {"version": "2.0"}, "images": [{"bufferView": 0}, {"bufferView": 0}], + "bufferViews": [{"buffer": 0, "byteLength": 76}], "buffers": [{"byteLength": 76}]})", + Bytes(kPng), limit); }; - be32(zlib, (b << 16) | a); - auto chunk = [&](const char* type, const std::vector& data) { - be32(png, static_cast(data.size())); - std::vector 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))); + const auto shared = twice(std::nullopt); + Check(shared && shared->images.size() == 2 && shared->images[1].pixels.size() == 4 && shared->images[1].pixels[3].a == 0, + "two images of one bufferView both come back"); + Check(twice(32).has_value(), "two 2x2 images fit a limit of 32 bytes"); + CheckError(twice(20), "image 1 decodes past the limit of 20 bytes", "the second one doesn't fit a limit of 20 bytes"); + + // A 16-bit PNG that stb's 32-bit arithmetic overflowed decoding: turned + // down from its header, whose IDAT here is no image at all. + const auto huge = Image::LoadPNGFromMemory(Png(32768, 16385, 16, 0, {0x78, 0x01})); + CheckError(huge, "pixels in all", "a 32768x16385 16-bit PNG is turned down from its header, before decoding"); + const auto narrow = Image::LoadPNGFromMemory(WidePng(16)); + Check(narrow && narrow->sizeX == 16 && narrow->sizeY == 1, "the test's PNG decodes"); + CheckError(Image::LoadPNGFromMemory(WidePng(70000)), "65535 a side", "an image 70000 wide is an error"); + + // A deflate block of the reserved type 3, which stb fails without a + // reason. After other failures, so a stale reason would show: reading + // its header, stb tries it as a JPEG first, which fails with "no SOI". + const std::vector reasonless = Png(1, 1, 8, 6, {0x78, 0x01, 0x07}); + CheckError(Image::LoadPNGFromMemory(Bytes(std::array{1, 2, 3, 4})), "unknown image type", + "bytes that are no image are an error"); + const auto noReason = Image::LoadPNGFromMemory(reasonless); + Check(!noReason && noReason.error() == "corrupt image", + noReason ? "an image stb fails without a reason is an error" : std::format("an image stb fails without a reason is an error, not a stale reason ({})", noReason.error())); + CheckError(LoadJson(R"({"asset": {"version": "2.0"}, "images": [{"bufferView": 0}], + "bufferViews": [{"buffer": 0, "byteLength": )" + std::to_string(reasonless.size()) + "}]}", + reasonless), + "image 0 doesn't decode", "a GLB with that image in it is an error"); + +#if __cpp_exceptions + // LoadPNG throws for a file it can't decode, which crafter-build catches + // to report the file, rather than aborting. + const std::filesystem::path file = TempFile("reasonless.png"); + std::ofstream(file, std::ios::binary).write(reinterpret_cast(reasonless.data()), static_cast(reasonless.size())); + for (const std::filesystem::path& path : {file, std::filesystem::path("/nonexistent/texture.png")}) { + std::string message; + try { + (void)Image::LoadPNG(path); + } catch (const std::runtime_error& e) { + message = e.what(); } - png.insert(png.end(), typed.begin(), typed.end()); - be32(png, ~crc); + Check(message.find(path.filename().string()) != std::string::npos, + std::format("LoadPNG throws std::runtime_error for {} ({})", path.filename().string(), message)); + } + std::filesystem::remove(file); +#endif +} + +void TestDecodeLimit() { + CheckError(GlbAsset::Load(BuildGlb(FixtureJson(), FixtureBin()), 0), "past the limit of 0 bytes", "the fixture doesn't fit a limit of 0 bytes"); + // Three primitives drawing the one accessor, each 48 bytes decoded: 36 + // of positions, 12 of indices. + auto shared = [](std::uint64_t limit) { + return LoadJson(R"({"asset": {"version": "2.0"}, + "meshes": [{"primitives": [{"attributes": {"POSITION": 0}}, {"attributes": {"POSITION": 0}}, {"attributes": {"POSITION": 0}}]}], + "accessors": [{"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3"}], + "bufferViews": [{"buffer": 0, "byteLength": 36}], "buffers": [{"byteLength": 48}]})", + QuadBin(), limit); }; - std::vector 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 out(png.size()); - std::memcpy(out.data(), png.data(), png.size()); - return out; + Check(shared(144).has_value(), "three primitives sharing an accessor fit a limit of 3 x 48 bytes"); + CheckError(shared(100), "mesh 0 primitive 2 decodes past the limit", "the third doesn't fit a limit of 100 bytes"); } void TestBrokenFiles() { @@ -396,69 +875,116 @@ void TestBrokenFiles() { std::vector 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"); + CheckError(GlbAsset::Load(badMagic), "magic", "bad magic is an error"); const std::vector truncated(glb.begin(), glb.begin() + static_cast(glb.size() / 2)); Check(!GlbAsset::Load(truncated), "a truncated file is an error"); + // A sparse accessor: its values written over its bufferView's. Accessor + // 6, the second primitive's positions, gets the interleaved view's first + // position, (0, 0, 0), for its element 0. const auto sparse = GlbAsset::Load(BuildGlb( - FixtureJson(R"(, "sparse": {"count": 1, "indices": {"bufferView": 2, "componentType": 5121}, "values": {"bufferView": 5}})"), + FixtureJson(R"(, "sparse": {"count": 1, "indices": {"bufferView": 2, "componentType": 5121}, "values": {"bufferView": 0}})"), FixtureBin())); - Check(!sparse && sparse.error().find("sparse") != std::string::npos, "a sparse accessor is an error"); + Check(sparse && Near(sparse->meshes[0].primitives[1].positions[0].z, 0) && Near(sparse->meshes[0].primitives[1].positions[1].z, 1), + sparse ? "a sparse accessor's values replace its view's" : std::format("a sparse accessor's values replace its view's: {}", sparse.error())); + CheckError(GlbAsset::Load(BuildGlb( + FixtureJson(R"(, "sparse": {"count": 3, "indices": {"bufferView": 3, "componentType": 5121}, "values": {"bufferView": 5}})"), + FixtureBin())), + "sparse indices don't rise", "sparse indices that don't rise are 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"); + CheckError(GlbAsset::Load(BuildGlb(FixtureJson("", 3), FixtureBin())), "bufferView", "an accessor past its buffer view is an error"); - Check(!GlbAsset::Load(std::filesystem::path("/nonexistent/file.glb")), "a missing file is an error"); + CheckError(GlbAsset::Load(std::filesystem::path("/nonexistent/file.glb")), "No such file", "a missing file is an error that says so"); - // The fixture's JSON with each `from` replaced by its `to`. + // The fixture's JSON with each `from` replaced by its `to`, and its BIN + // padded to 1 KiB, for views longer than the fixture's. auto with = [](std::initializer_list> changes) { - std::string json = FixtureJson(); - for (const auto& [from, to] : changes) json.replace(json.find(from), from.size(), to); - return BuildGlb(json, FixtureBin()); + std::vector bin = FixtureBin(); + bin.resize(1024); + return BuildGlb(Replace(FixtureJson(), changes), bin); }; - // 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 stride past 252, in a view long enough for its 3 elements at that + // stride: only the stride's range turns it down. + CheckError(GlbAsset::Load(with({{R"("byteLength": 72, "byteStride": 24)", R"("byteLength": 600, "byteStride": 256)"}})), "byteStride", + "a byteStride outside 4..252 is an error"); // 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"); + CheckError(GlbAsset::Load(with({{R"("joints": [0, 1],)", R"("joints": [0, 1], "skeleton": 99,)"}})), "skeleton", + "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. + // The first chunk's length, past the end of the file. The loader works + // in 32 bits, as the header does, where this length added to the chunk's + // offset, 12, and its header, 8, wraps round to 12: a check made that way + // would pass it, and step from the chunk back to itself forever. std::vector longChunk = glb; - const std::uint32_t huge = 0xFFFFFFF0; + const std::uint32_t huge = 0xFFFFFFF8; std::memcpy(longChunk.data() + 12, &huge, sizeof(huge)); - Check(!GlbAsset::Load(longChunk), "a chunk longer than the file is an error"); + CheckError(GlbAsset::Load(longChunk), "truncated chunk", "a chunk longer than the file is an error"); - // Nodes alone: a cycle, and a long chain. + // Files on disk: a header that claims 4 GiB in a 12-byte file, and one + // that isn't a GLB at all. + const std::filesystem::path file = TempFile("header.glb"); + auto load = [&](std::span bytes) { + std::ofstream(file, std::ios::binary).write(reinterpret_cast(bytes.data()), static_cast(bytes.size())); + return GlbAsset::Load(file); + }; + std::vector claims4GiB(glb.begin(), glb.begin() + 12); + const std::uint32_t fourGiB = 0xFFFFFFFF; + std::memcpy(claims4GiB.data() + 8, &fourGiB, sizeof(fourGiB)); + CheckError(load(claims4GiB), "truncated", "a header that claims more than the file has is an error"); + CheckError(load(Bytes(kPng)), "magic", "a file that isn't a GLB is turned down from its header"); + std::filesystem::remove(file); + + // Extras nested 200,000 deep, which Json::dump recursed into once a + // level and ran out of stack. + constexpr std::size_t kDepth = 200000; + const std::string nested = std::string(kDepth, '[') + std::string(kDepth, ']'); + const auto deepExtras = LoadJson(std::format(R"({{"asset": {{"version": "2.0"}}, "nodes": [{{"extras": {}}}]}})", nested), {}); + Check(deepExtras && deepExtras->nodes[0].extras == nested, "extras nested 200,000 deep come back as text"); + + // Nodes alone: a cycle, and long chains. 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"); + CheckError(GlbAsset::Load(nodesOnly(R"({"children": [1]}, {"children": [2]}, {"children": [0]})")), "cycle", + "children that form a cycle are an error"); + auto chainOf = [&](std::size_t depth) { + std::string chain; + for (std::size_t i = 0; i + 1 < depth; ++i) chain += std::format(R"({{"children": [{}]}},)", i + 1); + chain += "{}"; + return nodesOnly(chain); + }; + // Loads of a chain until one takes less than `within`, three at most: the + // last load, and the fastest one's time, so that a busy machine counts + // for less. + using Micro = std::chrono::microseconds; + auto timed = [](const std::vector& glb, Micro within) { + std::expected last = std::unexpected(std::string()); + Micro fastest = Micro::max(); + for (int run = 0; run < 3 && fastest >= within; ++run) { + last = std::unexpected(std::string()); // freed before the clock starts + const auto started = std::chrono::steady_clock::now(); + auto loaded = GlbAsset::Load(glb); + fastest = std::min(fastest, std::chrono::duration_cast(std::chrono::steady_clock::now() - started)); + last = std::move(loaded); + } + return std::pair(std::move(last), fastest); + }; + // Timed against a chain a quarter as deep rather than against the clock, + // which a debug build on a busy machine can take any time to beat: in + // proportion to depth it takes 4 times as long, walking up to the root + // from every node 16. 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; + const Micro quarter = timed(chainOf(kChain / 4), Micro::zero()).second; + const auto [deep, whole] = timed(chainOf(kChain), quarter * 8); Check(deep && deep->nodes.size() == kChain && deep->nodes.back().parent == static_cast(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(took).count())); - - const auto narrow = TextureAsset::LoadPNGFromMemory(WidePng(16)); - Check(narrow && narrow->sizeX == 16 && narrow->sizeY == 1, "the test's PNG decodes"); - const auto wide = TextureAsset::LoadPNGFromMemory(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"); + Check(whole < quarter * 8, std::format("in time in proportion to its depth ({} ms, against {} ms a quarter as deep)", whole.count() / 1000.0, + quarter.count() / 1000.0)); } // Column-major 4x4 helpers for the rest-pose check, kept separate from the @@ -491,8 +1017,7 @@ Mat LocalMatrix(const GlbNode& node) { 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; + if (!CheckLoads(loaded, std::format("{} loads", path))) return; const GlbAsset& a = *loaded; std::size_t vertices = 0; @@ -512,8 +1037,8 @@ void TestRealFile(const char* path) { 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"); + Check(std::ranges::all_of(a.images, [](const auto& image) { return image.pixels.size() == std::size_t(image.sizeX) * image.sizeY; }), + "every PNG and JPEG image decodes"); // World matrices by walking down from the roots, then world * inverseBind // per joint. @@ -541,6 +1066,13 @@ void TestRealFile(const char* path) { int main() { TestFixture(); TestRoom(); + TestMorphTargets(); + TestPrimitiveModes(); + TestTangents(); + TestSkinning(); + TestReferences(); + TestImages(); + TestDecodeLimit(); TestBrokenFiles(); if (const char* real = std::getenv("CRAFTER_ASSET_TEST_GLB")) { TestRealFile(real);