diff --git a/doc/lua_api.md b/doc/lua_api.md index 6989f3483..d14dd57f5 100644 --- a/doc/lua_api.md +++ b/doc/lua_api.md @@ -274,7 +274,7 @@ Accepted formats are: images: .png, .jpg, .tga, (deprecated:) .bmp sounds: .ogg vorbis - models: .x, .b3d, .obj, .gltf (Minetest 5.10 or newer) + models: .x, .b3d, .obj, .gltf, .glb (Minetest 5.10 or newer) Other formats won't be sent to the client (e.g. you can store .blend files in a folder for convenience, without the risk that such files are transferred) @@ -302,6 +302,9 @@ The glTF model file format for now only serves as a more modern alternative to the other static model file formats; it unlocks no special rendering features. +Binary glTF (`.glb`) files are supported and recommended over `.gltf` files +due to their space savings. + This means that many glTF features are not supported *yet*, including: * Animation diff --git a/games/devtest/mods/gltf/init.lua b/games/devtest/mods/gltf/init.lua index b5c2032bc..294da9145 100644 --- a/games/devtest/mods/gltf/init.lua +++ b/games/devtest/mods/gltf/init.lua @@ -18,6 +18,14 @@ do register_entity("blender_cube", cube_textures) register_entity("blender_cube_scaled", cube_textures) register_entity("blender_cube_matrix_transform", cube_textures) + minetest.register_entity("gltf:blender_cube_glb", { + initial_properties = { + visual = "mesh", + mesh = "gltf_blender_cube.glb", + textures = cube_textures, + backface_culling = true, + }, + }) end register_entity("snow_man", {"gltf_snow_man.png"}) register_entity("spider", {"gltf_spider.png"}) diff --git a/games/devtest/mods/gltf/models/gltf_blender_cube.glb b/games/devtest/mods/gltf/models/gltf_blender_cube.glb new file mode 100644 index 000000000..b1894fc4f Binary files /dev/null and b/games/devtest/mods/gltf/models/gltf_blender_cube.glb differ diff --git a/irr/src/CGLTFMeshFileLoader.cpp b/irr/src/CGLTFMeshFileLoader.cpp index 64bbc10f1..2d11fe456 100644 --- a/irr/src/CGLTFMeshFileLoader.cpp +++ b/irr/src/CGLTFMeshFileLoader.cpp @@ -14,8 +14,6 @@ #include "vector3d.h" #include "os.h" -#include "tiniergltf.hpp" - #include #include #include @@ -303,13 +301,11 @@ std::array SelfType::getNormalizedValues( return values; } -/** - * The most basic portion of the code base. This tells irllicht if this file has a .gltf extension. -*/ bool SelfType::isALoadableFileExtension( const io::path& filename) const { - return core::hasFileExtension(filename, "gltf"); + return core::hasFileExtension(filename, "gltf") || + core::hasFileExtension(filename, "glb"); } /** @@ -663,6 +659,7 @@ void SelfType::MeshExtractor::copyTCoords( */ std::optional SelfType::tryParseGLTF(io::IReadFile* file) { + const bool isGlb = core::hasFileExtension(file->getFileName(), "glb"); auto size = file->getSize(); if (size < 0) // this can happen if `ftell` fails return std::nullopt; @@ -671,15 +668,11 @@ std::optional SelfType::tryParseGLTF(io::IReadFile* file) return std::nullopt; // We probably don't need this, but add it just to be sure. buf[size] = '\0'; - Json::CharReaderBuilder builder; - const std::unique_ptr reader(builder.newCharReader()); - Json::Value json; - JSONCPP_STRING err; - if (!reader->parse(buf.get(), buf.get() + size, &json, &err)) { - return std::nullopt; - } try { - return tiniergltf::GlTF(json); + if (isGlb) + return tiniergltf::readGlb(buf.get(), size); + else + return tiniergltf::readGlTF(buf.get(), size); } catch (const std::runtime_error &e) { os::Printer::log("glTF loader", e.what(), ELL_ERROR); return std::nullopt; diff --git a/lib/tiniergltf/tiniergltf.hpp b/lib/tiniergltf/tiniergltf.hpp index 6a861556e..18f88321a 100644 --- a/lib/tiniergltf/tiniergltf.hpp +++ b/lib/tiniergltf/tiniergltf.hpp @@ -1,6 +1,9 @@ #pragma once #include +#include "util/base64.h" + +#include #include #include #include @@ -13,7 +16,6 @@ #include #include #include -#include "util/base64.h" namespace tiniergltf { @@ -460,7 +462,8 @@ struct Buffer { std::optional name; std::string data; Buffer(const Json::Value &o, - const std::function &resolveURI) + const std::function &resolveURI, + std::optional &&glbData = std::nullopt) : byteLength(as(o["byteLength"])) { check(o.isObject()); @@ -468,24 +471,32 @@ struct Buffer { if (o.isMember("name")) { name = as(o["name"]); } - check(o.isMember("uri")); - bool dataURI = false; - const std::string uri = as(o["uri"]); - for (auto &prefix : std::array { - "data:application/octet-stream;base64,", - "data:application/gltf-buffer;base64," - }) { - if (std::string_view(uri).substr(0, prefix.length()) == prefix) { - auto view = std::string_view(uri).substr(prefix.length()); - check(base64_is_valid(view)); - data = base64_decode(view); - dataURI = true; - break; + if (glbData.has_value()) { + check(!o.isMember("uri")); + data = *std::move(glbData); + // GLB allows padding, which need not be reflected in the JSON + check(byteLength + 3 >= data.size()); + check(data.size() >= byteLength); + } else { + check(o.isMember("uri")); + bool dataURI = false; + const std::string uri = as(o["uri"]); + for (auto &prefix : std::array { + "data:application/octet-stream;base64,", + "data:application/gltf-buffer;base64," + }) { + if (std::string_view(uri).substr(0, prefix.length()) == prefix) { + auto view = std::string_view(uri).substr(prefix.length()); + check(base64_is_valid(view)); + data = base64_decode(view); + dataURI = true; + break; + } } + if (!dataURI) + data = resolveURI(uri); + check(data.size() >= byteLength); } - if (!dataURI) - data = resolveURI(uri); - check(data.size() >= byteLength); data.resize(byteLength); } }; @@ -1093,6 +1104,12 @@ struct Texture { }; template<> Texture as(const Json::Value &o) { return o; } +using UriResolver = std::function; +static std::string uriError(const std::string &uri) { + // only base64 data URI support by default + throw std::runtime_error("unsupported URI: " + uri); +} + struct GlTF { std::optional> accessors; std::optional> animations; @@ -1111,12 +1128,10 @@ struct GlTF { std::optional> scenes; std::optional> skins; std::optional> textures; - static std::string uriError(const std::string &uri) { - // only base64 data URI support by default - throw std::runtime_error("unsupported URI: " + uri); - } + GlTF(const Json::Value &o, - const std::function &resolveURI = uriError) + const UriResolver &resolveUri = uriError, + std::optional &&glbData = std::nullopt) : asset(as(o["asset"])) { check(o.isObject()); @@ -1138,7 +1153,8 @@ struct GlTF { std::vector bufs; bufs.reserve(b.size()); for (Json::ArrayIndex i = 0; i < b.size(); ++i) { - bufs.emplace_back(b[i], resolveURI); + bufs.emplace_back(b[i], resolveUri, + i == 0 ? std::move(glbData) : std::nullopt); } check(bufs.size() >= 1); buffers = std::move(bufs); @@ -1354,4 +1370,123 @@ struct GlTF { } }; +// std::span is C++ 20, so we roll our own little struct here. +template +struct Span { + T *ptr; + uint32_t len; + bool empty() const { + return len == 0; + } + T *end() const { + return ptr + len; + } + template + Span cast() const { + return {(U *) ptr, len}; + } +}; + +static Json::Value readJson(Span span) { + Json::CharReaderBuilder builder; + const std::unique_ptr reader(builder.newCharReader()); + Json::Value json; + JSONCPP_STRING err; + if (!reader->parse(span.ptr, span.end(), &json, &err)) + throw std::runtime_error("invalid JSON"); + return json; +} + +inline GlTF readGlb(const char *data, std::size_t len, const UriResolver &resolveUri = uriError) { + struct Chunk { + uint32_t type; + Span span; + }; + + struct Stream { + Span span; + + bool eof() const { + return span.empty(); + } + + void advance(uint32_t n) { + span.len -= n; + span.ptr += n; + } + + uint32_t readUint32() { + if (span.len < 4) + throw std::runtime_error("premature EOF"); + uint32_t res = 0; + for (int i = 0; i < 4; ++i) + res += span.ptr[i] << (i * 8); + advance(4); + return res; + } + + Chunk readChunk() { + const auto chunkLen = readUint32(); + if (chunkLen % 4 != 0) + throw std::runtime_error("chunk length must be multiple of 4"); + const auto chunkType = readUint32(); + + auto chunkPtr = span.ptr; + if (span.len < chunkLen) + throw std::runtime_error("premature EOF"); + advance(chunkLen); + return {chunkType, {chunkPtr, chunkLen}}; + } + }; + + constexpr uint32_t MAGIC_glTF = 0x46546C67; + constexpr uint32_t MAGIC_JSON = 0x4E4F534A; + constexpr uint32_t MAGIC_BIN = 0x004E4942; + + if (len > std::numeric_limits::max()) + throw std::runtime_error("too large"); + + Stream is{{(const uint8_t *) data, static_cast(len)}}; + + const auto magic = is.readUint32(); + if (magic != MAGIC_glTF) // glTF + throw std::runtime_error("wrong magic number"); + const auto version = is.readUint32(); + if (version != 2) + throw std::runtime_error("wrong version"); + const auto length = is.readUint32(); + if (length != len) + throw std::runtime_error("wrong length"); + + const auto json = is.readChunk(); + if (json.type != MAGIC_JSON) + throw std::runtime_error("expected JSON chunk"); + + std::optional buffer; + if (!is.eof()) { + const auto chunk = is.readChunk(); + if (chunk.type == MAGIC_BIN) + buffer = std::string((const char *) chunk.span.ptr, chunk.span.len); + else if (chunk.type == MAGIC_JSON) + throw std::runtime_error("unexpected chunk"); + // Ignore all other chunks. We still want to validate that + // 1. These chunks are valid; + // 2. These chunks are *not* JSON or BIN chunks + while (!is.eof()) { + const auto type = is.readChunk().type; + if (type == MAGIC_JSON || type == MAGIC_BIN) + throw std::runtime_error("unexpected chunk"); + } + } + + return GlTF(readJson(json.span.cast()), resolveUri, std::move(buffer)); +} + +inline GlTF readGlTF(const char *data, std::size_t len, const UriResolver &resolveUri = uriError) { + if (len > std::numeric_limits::max()) + throw std::runtime_error("too large"); + + return GlTF(readJson({data, static_cast(len)}), resolveUri); +} + } diff --git a/src/client/client.cpp b/src/client/client.cpp index dedbebbb4..498e47b58 100644 --- a/src/client/client.cpp +++ b/src/client/client.cpp @@ -827,7 +827,7 @@ bool Client::loadMedia(const std::string &data, const std::string &filename, } const char *model_ext[] = { - ".x", ".b3d", ".obj", ".gltf", + ".x", ".b3d", ".obj", ".gltf", ".glb", NULL }; name = removeStringEnd(filename, model_ext); diff --git a/src/server.cpp b/src/server.cpp index c76155015..eb145ec12 100644 --- a/src/server.cpp +++ b/src/server.cpp @@ -2465,7 +2465,7 @@ bool Server::addMediaFile(const std::string &filename, const char *supported_ext[] = { ".png", ".jpg", ".bmp", ".tga", ".ogg", - ".x", ".b3d", ".obj", ".gltf", + ".x", ".b3d", ".obj", ".gltf", ".glb", // Custom translation file format ".tr", NULL diff --git a/src/unittest/test_irr_gltf_mesh_loader.cpp b/src/unittest/test_irr_gltf_mesh_loader.cpp index 8ab57e590..3cfaa990a 100644 --- a/src/unittest/test_irr_gltf_mesh_loader.cpp +++ b/src/unittest/test_irr_gltf_mesh_loader.cpp @@ -82,7 +82,10 @@ SECTION("minimal triangle") { } SECTION("blender cube") { - const auto mesh = loadMesh(model_stem + "blender_cube.gltf"); + const auto path = GENERATE( + model_stem + "blender_cube.gltf", + model_stem + "blender_cube.glb"); + const auto mesh = loadMesh(path); REQUIRE(mesh != nullptr); REQUIRE(mesh->getMeshBufferCount() == 1); SECTION("vertex coordinates are correct") {