From fbf3e7fc2eeac4e25869ba5adef46db0be21f311 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sat, 31 Oct 2026 03:25:12 +0100 Subject: [PATCH] fix(glb): the code review's findings, and morph targets Images (Crafter.Asset:Texture): - LoadPNGFromMemory reads an image's size from its header before decoding and refuses more than 65535 a side or 2^28 pixels: stb's 16-bit PNG path sized its buffer in 32 bits and overflowed the heap from 2^29. LoadPNG reads its file and goes the same way. SizeFromMemory is public. - A failure stb gives no reason for no longer passes NULL to std::string, and the reason is cleared before each decode, so it is never an older one. - LoadPNG throws std::runtime_error for a file that doesn't load, which crafter-build's try/catch reports per file; without exceptions it is Fatal. Bounds (Crafter.Asset:GLB): - Load takes an optional decodeLimit, by default 1 GiB plus 64 bytes for each byte of the file. Vertices, indices and pixels are paid for before they are allocated, so primitives or images naming the same data can't exhaust memory. - extras are written without recursion: deep nesting overflowed the stack. - The skinned-joint check is O(vertices + nodes), not O(nodes x vertices). - Load(path) reads the 12-byte header before the rest, and says why a path doesn't open. - Chunk arithmetic is in 32 bits, as the header's is, ordered so that it can't wrap round. Correctness: - Indices are range-checked before they are narrowed to 32 bits, and whole numbers written as 24.0 read as integers. - Missing tangents come from the UV set the normal texture samples, and a zero normal no longer makes them NaN. - A file that requires an extension the loader doesn't implement is an error. - Only PNG and JPEG are decoded; other formats (WebP, KTX2) stay empty, and no texture may use one. An image repeating another's bufferView is copied. - Strips and fans become lists; points and lines come back empty. - texCoord is 0 or 1, and every primitive whose material samples it has it. - JOINTS_n and WEIGHTS_n pair up, the four heaviest influences are kept, a vertex without weight goes to its first joint, and a skinned node's mesh has joints in every primitive. - The default scene exists, and its roots are distinct root nodes. - Sampler enums are glTF's. New: - TEXCOORD_1, as uv1. - Morph targets with the mesh's and nodes' weights; sparse accessors and ones without a bufferView; the meshes' extras; the BIN chunk. - Images are TextureAsset>; GlbRGBA8 is gone. Tests: each of the above. The deep chain is timed against one a quarter as deep instead of a 2 s clock, which flaked in debug builds; the byteStride and chunk-length tests now fail when the check they test is removed. Co-Authored-By: Claude Opus 5.5 --- implementations/Crafter.Asset-GLB.cpp | 952 +++++++++++++++++++------- interfaces/Crafter.Asset-GLB.cppm | 87 ++- interfaces/Crafter.Asset-Texture.cppm | 103 ++- tests/GLB/main.cpp | 722 ++++++++++++++++--- 4 files changed, 1466 insertions(+), 398 deletions(-) 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);