Add binary glTF (.glb) support

This commit is contained in:
Lars Mueller 2024-09-05 17:16:55 +02:00
parent 486dc3288d
commit 1859470b14
8 changed files with 184 additions and 42 deletions

View File

@ -274,7 +274,7 @@ Accepted formats are:
images: .png, .jpg, .tga, (deprecated:) .bmp images: .png, .jpg, .tga, (deprecated:) .bmp
sounds: .ogg vorbis 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 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) 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; more modern alternative to the other static model file formats;
it unlocks no special rendering features. 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: This means that many glTF features are not supported *yet*, including:
* Animation * Animation

View File

@ -18,6 +18,14 @@ do
register_entity("blender_cube", cube_textures) register_entity("blender_cube", cube_textures)
register_entity("blender_cube_scaled", cube_textures) register_entity("blender_cube_scaled", cube_textures)
register_entity("blender_cube_matrix_transform", 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 end
register_entity("snow_man", {"gltf_snow_man.png"}) register_entity("snow_man", {"gltf_snow_man.png"})
register_entity("spider", {"gltf_spider.png"}) register_entity("spider", {"gltf_spider.png"})

Binary file not shown.

View File

@ -14,8 +14,6 @@
#include "vector3d.h" #include "vector3d.h"
#include "os.h" #include "os.h"
#include "tiniergltf.hpp"
#include <array> #include <array>
#include <cstddef> #include <cstddef>
#include <cstring> #include <cstring>
@ -303,13 +301,11 @@ std::array<f32, N> SelfType::getNormalizedValues(
return values; return values;
} }
/**
* The most basic portion of the code base. This tells irllicht if this file has a .gltf extension.
*/
bool SelfType::isALoadableFileExtension( bool SelfType::isALoadableFileExtension(
const io::path& filename) const 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<tiniergltf::GlTF> SelfType::tryParseGLTF(io::IReadFile* file) std::optional<tiniergltf::GlTF> SelfType::tryParseGLTF(io::IReadFile* file)
{ {
const bool isGlb = core::hasFileExtension(file->getFileName(), "glb");
auto size = file->getSize(); auto size = file->getSize();
if (size < 0) // this can happen if `ftell` fails if (size < 0) // this can happen if `ftell` fails
return std::nullopt; return std::nullopt;
@ -671,15 +668,11 @@ std::optional<tiniergltf::GlTF> SelfType::tryParseGLTF(io::IReadFile* file)
return std::nullopt; return std::nullopt;
// We probably don't need this, but add it just to be sure. // We probably don't need this, but add it just to be sure.
buf[size] = '\0'; buf[size] = '\0';
Json::CharReaderBuilder builder;
const std::unique_ptr<Json::CharReader> reader(builder.newCharReader());
Json::Value json;
JSONCPP_STRING err;
if (!reader->parse(buf.get(), buf.get() + size, &json, &err)) {
return std::nullopt;
}
try { 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) { } catch (const std::runtime_error &e) {
os::Printer::log("glTF loader", e.what(), ELL_ERROR); os::Printer::log("glTF loader", e.what(), ELL_ERROR);
return std::nullopt; return std::nullopt;

View File

@ -1,6 +1,9 @@
#pragma once #pragma once
#include <json/json.h> #include <json/json.h>
#include "util/base64.h"
#include <cstdint>
#include <functional> #include <functional>
#include <stack> #include <stack>
#include <string> #include <string>
@ -13,7 +16,6 @@
#include <stdexcept> #include <stdexcept>
#include <unordered_map> #include <unordered_map>
#include <unordered_set> #include <unordered_set>
#include "util/base64.h"
namespace tiniergltf { namespace tiniergltf {
@ -460,7 +462,8 @@ struct Buffer {
std::optional<std::string> name; std::optional<std::string> name;
std::string data; std::string data;
Buffer(const Json::Value &o, Buffer(const Json::Value &o,
const std::function<std::string(const std::string &uri)> &resolveURI) const std::function<std::string(const std::string &uri)> &resolveURI,
std::optional<std::string> &&glbData = std::nullopt)
: byteLength(as<std::size_t>(o["byteLength"])) : byteLength(as<std::size_t>(o["byteLength"]))
{ {
check(o.isObject()); check(o.isObject());
@ -468,6 +471,13 @@ struct Buffer {
if (o.isMember("name")) { if (o.isMember("name")) {
name = as<std::string>(o["name"]); name = as<std::string>(o["name"]);
} }
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")); check(o.isMember("uri"));
bool dataURI = false; bool dataURI = false;
const std::string uri = as<std::string>(o["uri"]); const std::string uri = as<std::string>(o["uri"]);
@ -486,6 +496,7 @@ struct Buffer {
if (!dataURI) if (!dataURI)
data = resolveURI(uri); data = resolveURI(uri);
check(data.size() >= byteLength); check(data.size() >= byteLength);
}
data.resize(byteLength); data.resize(byteLength);
} }
}; };
@ -1093,6 +1104,12 @@ struct Texture {
}; };
template<> Texture as(const Json::Value &o) { return o; } template<> Texture as(const Json::Value &o) { return o; }
using UriResolver = std::function<std::string(const std::string &uri)>;
static std::string uriError(const std::string &uri) {
// only base64 data URI support by default
throw std::runtime_error("unsupported URI: " + uri);
}
struct GlTF { struct GlTF {
std::optional<std::vector<Accessor>> accessors; std::optional<std::vector<Accessor>> accessors;
std::optional<std::vector<Animation>> animations; std::optional<std::vector<Animation>> animations;
@ -1111,12 +1128,10 @@ struct GlTF {
std::optional<std::vector<Scene>> scenes; std::optional<std::vector<Scene>> scenes;
std::optional<std::vector<Skin>> skins; std::optional<std::vector<Skin>> skins;
std::optional<std::vector<Texture>> textures; std::optional<std::vector<Texture>> 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, GlTF(const Json::Value &o,
const std::function<std::string(const std::string &uri)> &resolveURI = uriError) const UriResolver &resolveUri = uriError,
std::optional<std::string> &&glbData = std::nullopt)
: asset(as<Asset>(o["asset"])) : asset(as<Asset>(o["asset"]))
{ {
check(o.isObject()); check(o.isObject());
@ -1138,7 +1153,8 @@ struct GlTF {
std::vector<Buffer> bufs; std::vector<Buffer> bufs;
bufs.reserve(b.size()); bufs.reserve(b.size());
for (Json::ArrayIndex i = 0; i < b.size(); ++i) { 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); check(bufs.size() >= 1);
buffers = std::move(bufs); buffers = std::move(bufs);
@ -1354,4 +1370,123 @@ struct GlTF {
} }
}; };
// std::span is C++ 20, so we roll our own little struct here.
template <typename T>
struct Span {
T *ptr;
uint32_t len;
bool empty() const {
return len == 0;
}
T *end() const {
return ptr + len;
}
template <typename U>
Span<U> cast() const {
return {(U *) ptr, len};
}
};
static Json::Value readJson(Span<const char> span) {
Json::CharReaderBuilder builder;
const std::unique_ptr<Json::CharReader> 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<const uint8_t> span;
};
struct Stream {
Span<const uint8_t> 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<uint32_t>::max())
throw std::runtime_error("too large");
Stream is{{(const uint8_t *) data, static_cast<uint32_t>(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<std::string> 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<const char>()), resolveUri, std::move(buffer));
}
inline GlTF readGlTF(const char *data, std::size_t len, const UriResolver &resolveUri = uriError) {
if (len > std::numeric_limits<uint32_t>::max())
throw std::runtime_error("too large");
return GlTF(readJson({data, static_cast<uint32_t>(len)}), resolveUri);
}
} }

View File

@ -827,7 +827,7 @@ bool Client::loadMedia(const std::string &data, const std::string &filename,
} }
const char *model_ext[] = { const char *model_ext[] = {
".x", ".b3d", ".obj", ".gltf", ".x", ".b3d", ".obj", ".gltf", ".glb",
NULL NULL
}; };
name = removeStringEnd(filename, model_ext); name = removeStringEnd(filename, model_ext);

View File

@ -2465,7 +2465,7 @@ bool Server::addMediaFile(const std::string &filename,
const char *supported_ext[] = { const char *supported_ext[] = {
".png", ".jpg", ".bmp", ".tga", ".png", ".jpg", ".bmp", ".tga",
".ogg", ".ogg",
".x", ".b3d", ".obj", ".gltf", ".x", ".b3d", ".obj", ".gltf", ".glb",
// Custom translation file format // Custom translation file format
".tr", ".tr",
NULL NULL

View File

@ -82,7 +82,10 @@ SECTION("minimal triangle") {
} }
SECTION("blender cube") { 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 != nullptr);
REQUIRE(mesh->getMeshBufferCount() == 1); REQUIRE(mesh->getMeshBufferCount() == 1);
SECTION("vertex coordinates are correct") { SECTION("vertex coordinates are correct") {