fixed b3d reader & writer
This commit is contained in:
parent
a3de832711
commit
92c46ba799
9
init.lua
9
init.lua
@ -0,0 +1,9 @@
|
||||
|
||||
mtul.b3d = {}
|
||||
|
||||
local modpath = minetest.get_modpath("MTUL-b3d")
|
||||
dofile(modpath.."/read_b3d.lua")
|
||||
dofile(modpath.."/write_b3d.lua")
|
||||
--these modules are disabled, refactoring is needed.
|
||||
--dofile(modpath.."/read_b3d_bone.lua")
|
||||
--dofile(modpath.."/read_b3d_bone.lua"
|
6
mod.conf
Normal file
6
mod.conf
Normal file
@ -0,0 +1,6 @@
|
||||
name = "MTUL-b3d"
|
||||
title = "MTUL b3d standalone"
|
||||
description = "a b3d reader for minetest, cloned from modlib. Requires MTUL-core, also requires MTUL-math for bone reading implementations."
|
||||
depends = "MTUL-core"
|
||||
optional_depends = "MTUL-math"
|
||||
author = "FatalError42O, Appgurue"
|
302
read_b3d.lua
Normal file
302
read_b3d.lua
Normal file
@ -0,0 +1,302 @@
|
||||
-- Localize globals
|
||||
local read_int, read_single = mtul.binary.read_int, mtul.binary.read_single
|
||||
--+ Reads a single BB3D chunk from a stream
|
||||
--+ Doing `assert(stream:read(1) == nil)` afterwards is recommended
|
||||
--+ See `b3d_specification.txt` as well as https://github.com/blitz-research/blitz3d/blob/master/blitz3d/loader_b3d.cpp
|
||||
--> B3D model
|
||||
function mtul.b3d.read(stream)
|
||||
local left = 8
|
||||
|
||||
local function byte()
|
||||
left = left - 1
|
||||
return assert(stream:read(1):byte())
|
||||
end
|
||||
|
||||
local function int()
|
||||
return read_int(byte, 4)
|
||||
end
|
||||
|
||||
local function id()
|
||||
return int() + 1
|
||||
end
|
||||
|
||||
local function optional_id()
|
||||
local id = int()
|
||||
if id == -1 then
|
||||
return
|
||||
end
|
||||
return id + 1
|
||||
end
|
||||
|
||||
local function string()
|
||||
local rope = {}
|
||||
while true do
|
||||
left = left - 1
|
||||
local char = assert(stream:read(1))
|
||||
if char == "\0" then
|
||||
return table.concat(rope)
|
||||
end
|
||||
table.insert(rope, char)
|
||||
end
|
||||
end
|
||||
|
||||
local function float()
|
||||
return read_single(byte)
|
||||
end
|
||||
|
||||
local function float_array(length)
|
||||
local list = {}
|
||||
for index = 1, length do
|
||||
list[index] = float()
|
||||
end
|
||||
return list
|
||||
end
|
||||
|
||||
local function color()
|
||||
local ret = {}
|
||||
ret.r = float()
|
||||
ret.g = float()
|
||||
ret.b = float()
|
||||
ret.a = float()
|
||||
return ret
|
||||
end
|
||||
|
||||
local function vector3()
|
||||
return float_array(3)
|
||||
end
|
||||
|
||||
local function quaternion()
|
||||
local w = float()
|
||||
local x = float()
|
||||
local y = float()
|
||||
local z = float()
|
||||
return {x, y, z, w}
|
||||
end
|
||||
|
||||
local function content()
|
||||
if left < 0 then
|
||||
error(("unexpected EOF at position %d"):format(stream:seek()))
|
||||
end
|
||||
return left ~= 0
|
||||
end
|
||||
|
||||
local chunk
|
||||
local chunks = {
|
||||
TEXS = function()
|
||||
local textures = {}
|
||||
while content() do
|
||||
local tex = {}
|
||||
tex.file = string()
|
||||
tex.flags = int()
|
||||
tex.blend = int()
|
||||
tex.pos = float_array(2)
|
||||
tex.scale = float_array(2)
|
||||
tex.rotation = float()
|
||||
table.insert(textures, tex)
|
||||
end
|
||||
return textures
|
||||
end,
|
||||
BRUS = function()
|
||||
local brushes = {}
|
||||
local n_texs = int()
|
||||
assert(n_texs <= 8)
|
||||
while content() do
|
||||
local brush = {}
|
||||
brush.name = string()
|
||||
brush.color = color()
|
||||
brush.shininess = float()
|
||||
brush.blend = int()
|
||||
brush.fx = int()
|
||||
brush.texture_id = {}
|
||||
for index = 1, n_texs do
|
||||
brush.texture_id[index] = optional_id()
|
||||
end
|
||||
table.insert(brushes, brush)
|
||||
end
|
||||
return brushes
|
||||
end,
|
||||
VRTS = function()
|
||||
local vertices = {}
|
||||
vertices.flags = int()
|
||||
vertices.tex_coord_sets = int()
|
||||
vertices.tex_coord_set_size = int()
|
||||
assert(vertices.tex_coord_sets <= 8 and vertices.tex_coord_set_size <= 4)
|
||||
local has_normal = (vertices.flags % 2 == 1) or nil
|
||||
local has_color = (math.floor(vertices.flags / 2) % 2 == 1) or nil
|
||||
while content() do
|
||||
local vertex = {}
|
||||
vertex.pos = vector3()
|
||||
vertex.normal = has_normal and vector3()
|
||||
vertex.color = has_color and color()
|
||||
vertex.tex_coords = {}
|
||||
for tex_coord_set = 1, vertices.tex_coord_sets do
|
||||
local tex_coords = {}
|
||||
for tex_coord = 1, vertices.tex_coord_set_size do
|
||||
tex_coords[tex_coord] = float()
|
||||
end
|
||||
vertex.tex_coords[tex_coord_set] = tex_coords
|
||||
end
|
||||
table.insert(vertices, vertex)
|
||||
end
|
||||
return vertices
|
||||
end,
|
||||
TRIS = function()
|
||||
local tris = {}
|
||||
tris.brush_id = id()
|
||||
tris.vertex_ids = {}
|
||||
while content() do
|
||||
local i = id()
|
||||
local j = id()
|
||||
local k = id()
|
||||
table.insert(tris.vertex_ids, {i, j, k})
|
||||
end
|
||||
return tris
|
||||
end,
|
||||
MESH = function()
|
||||
local mesh = {}
|
||||
mesh.brush_id = optional_id()
|
||||
mesh.vertices = chunk{VRTS = true}
|
||||
mesh.triangle_sets = {}
|
||||
repeat
|
||||
local tris = chunk{TRIS = true}
|
||||
table.insert(mesh.triangle_sets, tris)
|
||||
until not content()
|
||||
return mesh
|
||||
end,
|
||||
BONE = function()
|
||||
local bone = {}
|
||||
while content() do
|
||||
local vertex_id = id()
|
||||
assert(not bone[vertex_id], "duplicate vertex weight")
|
||||
local weight = float()
|
||||
if weight > 0 then
|
||||
-- Many exporters include unneeded zero weights
|
||||
bone[vertex_id] = weight
|
||||
end
|
||||
end
|
||||
return bone
|
||||
end,
|
||||
KEYS = function()
|
||||
local flags = int()
|
||||
local _flags = flags % 8
|
||||
local rotation, scale, position
|
||||
if _flags >= 4 then
|
||||
rotation = true
|
||||
_flags = _flags - 4
|
||||
end
|
||||
if _flags >= 2 then
|
||||
scale = true
|
||||
_flags = _flags - 2
|
||||
end
|
||||
position = _flags >= 1
|
||||
local bone = {
|
||||
flags = flags
|
||||
}
|
||||
while content() do
|
||||
local frame = {}
|
||||
frame.frame = int()
|
||||
if position then
|
||||
frame.position = vector3()
|
||||
end
|
||||
if scale then
|
||||
frame.scale = vector3()
|
||||
end
|
||||
if rotation then
|
||||
frame.rotation = quaternion()
|
||||
end
|
||||
table.insert(bone, frame)
|
||||
end
|
||||
return bone
|
||||
end,
|
||||
ANIM = function()
|
||||
local ret = {}
|
||||
ret.flags = int() -- flags are unused
|
||||
ret.frames = int()
|
||||
ret.fps = float()
|
||||
return ret
|
||||
end,
|
||||
NODE = function()
|
||||
local node = {}
|
||||
node.name = string()
|
||||
node.position = vector3()
|
||||
node.scale = vector3()
|
||||
node.keys = {}
|
||||
node.rotation = quaternion()
|
||||
node.children = {}
|
||||
local node_type
|
||||
-- See https://github.com/blitz-research/blitz3d/blob/master/blitz3d/loader_b3d.cpp#L263
|
||||
-- Order is not validated; double occurrences of mutually exclusive node def are
|
||||
while content() do
|
||||
local elem, type = chunk()
|
||||
if type == "MESH" then
|
||||
assert(not node_type)
|
||||
node_type = "mesh"
|
||||
node.mesh = elem
|
||||
elseif type == "BONE" then
|
||||
assert(not node_type)
|
||||
node_type = "bone"
|
||||
node.bone = elem
|
||||
elseif type == "KEYS" then
|
||||
mtul.tbl.append(node.keys, elem)
|
||||
elseif type == "NODE" then
|
||||
table.insert(node.children, elem)
|
||||
elseif type == "ANIM" then
|
||||
node.animation = elem
|
||||
else
|
||||
assert(not node_type)
|
||||
node_type = "pivot"
|
||||
end
|
||||
end
|
||||
-- Ensure frames are sorted ascendingly
|
||||
table.sort(node.keys, function(a, b)
|
||||
assert(a.frame ~= b.frame, "duplicate frame")
|
||||
return a.frame < b.frame
|
||||
end)
|
||||
return node
|
||||
end,
|
||||
BB3D = function()
|
||||
local version = int()
|
||||
local self = {
|
||||
version = {
|
||||
major = math.floor(version / 100),
|
||||
minor = version % 100,
|
||||
},
|
||||
textures = {},
|
||||
brushes = {}
|
||||
}
|
||||
assert(self.version.major <= 2, "unsupported version: " .. self.version.major)
|
||||
while content() do
|
||||
local field, type = chunk{TEXS = true, BRUS = true, NODE = true}
|
||||
if type == "TEXS" then
|
||||
mtul.tbl.append(self.textures, field)
|
||||
elseif type == "BRUS" then
|
||||
mtul.tbl.append(self.brushes, field)
|
||||
else
|
||||
self.node = field
|
||||
end
|
||||
end
|
||||
return self
|
||||
end
|
||||
}
|
||||
|
||||
local function chunk_header()
|
||||
left = left - 4
|
||||
return stream:read(4), int()
|
||||
end
|
||||
|
||||
function chunk(possible_chunks)
|
||||
local type, new_left = chunk_header()
|
||||
local parent_left
|
||||
left, parent_left = new_left, left
|
||||
if possible_chunks and not possible_chunks[type] then
|
||||
error("expected one of " .. table.concat(mtul.tbl.keys(possible_chunks), ", ") .. ", found " .. type)
|
||||
end
|
||||
local res = assert(chunks[type])()
|
||||
assert(left == 0)
|
||||
left = parent_left - new_left
|
||||
return res, type
|
||||
end
|
||||
|
||||
local self = chunk{BB3D = true}
|
||||
return setmetatable(self, mtul.b3d)
|
||||
end
|
70
read_b3d_bone.lua
Normal file
70
read_b3d_bone.lua
Normal file
@ -0,0 +1,70 @@
|
||||
|
||||
|
||||
--INACTIVE FILE
|
||||
|
||||
|
||||
local binary_search_frame = modlib.table.binary_search_comparator(function(a, b)
|
||||
return modlib.table.default_comparator(a, b.frame)
|
||||
end)
|
||||
|
||||
--> list of { bone_name = string, parent_bone_name = string, position = vector, rotation = quaternion, scale = vector }
|
||||
function get_animated_bone_properties(self, keyframe, interpolate)
|
||||
local function get_frame_values(keys)
|
||||
local values = keys[keyframe]
|
||||
if values and values.frame == keyframe then
|
||||
return {
|
||||
position = values.position,
|
||||
rotation = values.rotation,
|
||||
scale = values.scale
|
||||
}
|
||||
end
|
||||
local index = binary_search_frame(keys, keyframe)
|
||||
if index > 0 then
|
||||
return keys[index]
|
||||
end
|
||||
index = -index
|
||||
assert(index > 1 and index <= #keys)
|
||||
local a, b = keys[index - 1], keys[index]
|
||||
if not interpolate then
|
||||
return a
|
||||
end
|
||||
local ratio = (keyframe - a.frame) / (b.frame - a.frame)
|
||||
return {
|
||||
position = (a.position and b.position and modlib.vector.interpolate(a.position, b.position, ratio)) or a.position or b.position,
|
||||
rotation = (a.rotation and b.rotation and modlib.quaternion.slerp(a.rotation, b.rotation, ratio)) or a.rotation or b.rotation,
|
||||
scale = (a.scale and b.scale and modlib.vector.interpolate(a.scale, b.scale, ratio)) or a.scale or b.scale,
|
||||
}
|
||||
end
|
||||
local bone_properties = {}
|
||||
local function get_props(node, parent_bone_name)
|
||||
local properties = {parent_bone_name = parent_bone_name}
|
||||
|
||||
if keyframe > 0 and node.keys and next(node.keys) ~= nil then
|
||||
modlib.table.add_all(properties, get_frame_values(node.keys))
|
||||
end
|
||||
|
||||
if not properties.position then -- animation not present, fall back to node position
|
||||
properties.position = modlib.table.copy(node.position)
|
||||
end
|
||||
|
||||
if properties.rotation then -- animation is relative to node rotation
|
||||
properties.rotation = modlib.quaternion.compose(node.rotation, properties.rotation)
|
||||
else
|
||||
properties.rotation = modlib.table.copy(node.rotation)
|
||||
end
|
||||
|
||||
if not properties.scale then -- animation not present, fall back to node scale
|
||||
properties.scale = modlib.table.copy(node.scale)
|
||||
end
|
||||
|
||||
if node.bone then
|
||||
properties.bone_name = node.name
|
||||
table.insert(bone_properties, properties)
|
||||
end
|
||||
for _, child in pairs(node.children or {}) do
|
||||
get_props(child, properties.bone_name)
|
||||
end
|
||||
end
|
||||
get_props(self.node)
|
||||
return bone_properties
|
||||
end
|
546
to_gltf.lua
546
to_gltf.lua
@ -0,0 +1,546 @@
|
||||
|
||||
|
||||
--THIS FILE IS INACTIVE, VERY BROKEN
|
||||
|
||||
|
||||
--possibly unreliable, appgurueu (creator) says:
|
||||
-- B3D to glTF converter
|
||||
-- See https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html
|
||||
--! Highly experimental; expect bugs!
|
||||
|
||||
-- glTF constants
|
||||
local write_int, write_uint, write_single = mtul.binary.write_int, mtul.binary.write_uint, mtul.binary.write_single
|
||||
local array_buffer = 34962 -- "Buffer containing vertex attributes, such as vertices, texcoords or colors."
|
||||
local element_array_buffer = 34963 -- "Buffer used for element indices."
|
||||
local component_type = {
|
||||
signed_byte = 5120,
|
||||
unsigned_byte = 5121,
|
||||
signed_short = 5122,
|
||||
unsigned_short = 5123,
|
||||
unsigned_int = 5125,
|
||||
float = 5126,
|
||||
}
|
||||
|
||||
function write_gltf(self, file)
|
||||
modlib.json:write_file(self:to_gltf(), file)
|
||||
end
|
||||
|
||||
-- Coordinate system conversions:
|
||||
-- "Blitz 3D uses a left-handed system: X+ is to the right. Y+ is up. Z+ is forward."
|
||||
-- "glTF uses a right-handed coordinate system. glTF defines +Y as up, +Z as forward, and -X as right;
|
||||
-- the front of a glTF asset faces +Z."
|
||||
|
||||
local function translation_to_gltf(vec)
|
||||
return {-vec[1], vec[2], vec[3]} -- invert the X-axis
|
||||
end
|
||||
|
||||
local function quaternion_to_gltf(quat)
|
||||
-- TODO (!) is this correct?
|
||||
return {-quat[1], quat[2], quat[3], quat[4]} -- invert the X-axis
|
||||
end
|
||||
|
||||
-- Convert a color from table format to glTF RGBA list format
|
||||
local function color_to_gltf(col)
|
||||
return {col.r, col.g, col.b, col.a}
|
||||
end
|
||||
|
||||
-- Basic helpers for writing to the buffer, all parameterized in terms of `write_byte`
|
||||
|
||||
local function write_index(write_byte, index)
|
||||
write_uint(write_byte, index - 1 --[[1-based to 0-based]], 4)
|
||||
end
|
||||
|
||||
local function write_float(write_byte, float)
|
||||
assert(-math.huge < float and float < math.huge)
|
||||
assert(-math.huge < fround(float) and fround(float) < math.huge, ("%.18g got %.18g"):format(float, fround(float)))
|
||||
write_single(write_byte, fround(float))
|
||||
end
|
||||
|
||||
local function write_floats(write_byte, floats, expected_len)
|
||||
assert(#floats == expected_len)
|
||||
for i = 1, expected_len do
|
||||
write_float(write_byte, floats[i])
|
||||
end
|
||||
end
|
||||
|
||||
local function write_vector(write_byte, vec)
|
||||
return write_floats(write_byte, vec, 3)
|
||||
end
|
||||
|
||||
local function write_translation(write_byte, vec)
|
||||
return write_vector(write_byte, translation_to_gltf(vec))
|
||||
end
|
||||
|
||||
local function write_quaternion(write_byte, quat)
|
||||
-- XYZW order is already correct, but we still need to convert left-handed to right-handed
|
||||
return write_floats(write_byte, quaternion_to_gltf(quat), 4)
|
||||
end
|
||||
|
||||
function mtul.b3d.to_gltf(self)
|
||||
-- Accessor helper: Stores arrays of raw data in a buffer, produces views & accessors.
|
||||
-- Everything is dumped in the same large buffer.
|
||||
local buffer_rope = {} -- buffer content (table of strings)
|
||||
local buffer_views = {} -- glTF buffer views
|
||||
local accessors = {} -- glTF accessors
|
||||
local offset = 0 -- current byte offset
|
||||
local function add_accessor(
|
||||
type, -- name of the composite type (e.g. SCALAR, VEC3, VEC4, MAT4, ...)
|
||||
comp_type, -- name of the component type (e.g. float, unsigned_int, ...)
|
||||
index, -- true / false / nil: whether this is an index (true) or vertex data (false) or neither (nil)
|
||||
func -- `function(write_byte) ... return count, min, max end` to be called to write to the buffer view;
|
||||
-- the count of elements written must be returned; min and max may be returned
|
||||
)
|
||||
-- Always add padding to obtain a multiple of 4
|
||||
-- TODO (?) don't add padding if it isn't required
|
||||
table.insert(buffer_rope, ("\0"):rep(offset % 4))
|
||||
offset = math.ceil(offset / 4) * 4
|
||||
local bytes_written = 0
|
||||
local count, min, max = func(function(byte)
|
||||
table.insert(buffer_rope, string_char(byte))
|
||||
bytes_written = bytes_written + 1
|
||||
end)
|
||||
assert(count)
|
||||
|
||||
-- Add buffer view
|
||||
table.insert(buffer_views, {
|
||||
buffer = 0, -- 0-based - there only is one buffer
|
||||
byteOffset = offset,
|
||||
byteLength = bytes_written,
|
||||
target = ((index == true) and element_array_buffer) -- index data
|
||||
or ((index == false) and array_buffer) -- vertex data
|
||||
or nil, -- no target hint
|
||||
})
|
||||
table.insert(accessors, {
|
||||
bufferView = #buffer_views - 1, -- 0-based
|
||||
byteOffset = 0, -- view has correct offset
|
||||
componentType = assert(component_type[comp_type]),
|
||||
type = type,
|
||||
count = count,
|
||||
min = min,
|
||||
max = max,
|
||||
})
|
||||
|
||||
offset = offset + bytes_written
|
||||
return #accessors - 1 -- 0-based index of the accessor
|
||||
end
|
||||
|
||||
local textures = {} -- glTF textures
|
||||
local function add_texture(name)
|
||||
-- TODO (?) add an appropriate sampler
|
||||
table.insert(textures, {name = name})
|
||||
return #textures - 1 -- 0-based texture index
|
||||
end
|
||||
for _, tex in ipairs(self.textures) do
|
||||
-- Assert that all values we don't map properly yet are defaults
|
||||
-- TODO dig into Blitz3D sources to figure out the meaning of flags & blend
|
||||
-- TODO (...) deal with flag value of 65536:
|
||||
-- "The flags field value can conditional an additional flag value of '65536'.
|
||||
-- This is used to indicate that the texture uses secondary UV values, ala the TextureCoords command."
|
||||
assert(tex.flags == 1) -- TODO (?) see https://github.com/blitz-research/blitz3d/blob/master/gxruntime/gxcanvas.h#L59
|
||||
assert(tex.blend == 2)
|
||||
-- Assert that the texture isn't transformed
|
||||
assert(tex.rotation == 0)
|
||||
assert(tex.pos[1] == 0 and tex.pos[2] == 0)
|
||||
assert(tex.scale[1] == 1 and tex.scale[2] == 1)
|
||||
add_texture(tex.file)
|
||||
end
|
||||
|
||||
-- Map brushes to materials (& textures)
|
||||
local materials = {}
|
||||
for i, brush in ipairs(self.brushes) do
|
||||
-- Assert defaults
|
||||
-- See https://github.com/blitz-research/blitz3d/blob/6beb288cb5962393684a59a4a44ac11524894939/blitz3d/brush.cpp#L164-L167:
|
||||
-- 0 = default/replace, 1 = alpha, 2 = multiply, 3 = add
|
||||
assert(brush.blend == 1) -- (alpha)
|
||||
-- TODO (...) figure out what these "effects" are and if/how to map them to glTF
|
||||
assert(brush.fx == 0)
|
||||
assert(#brush.texture_id <= 1) -- TODO (...) this supports only a single texture per brush for now
|
||||
local index
|
||||
if brush.texture_id[1] then
|
||||
index = brush.texture_id[1] -- 0-based
|
||||
else
|
||||
-- Implementations seem to implicitly assume textures for brushes
|
||||
index = add_texture(brush.name)
|
||||
end
|
||||
materials[i] = {
|
||||
name = brush.name,
|
||||
alphaMode = "BLEND",
|
||||
pbrMetallicRoughness = {
|
||||
baseColorFactor = color_to_gltf(brush.color),
|
||||
metallicFactor = brush.shininess, -- TODO (?) are these really equivalent?
|
||||
-- Add texture if there is none
|
||||
baseColorTexture = {
|
||||
index = index,
|
||||
-- `texCoord = 0` is the default already, no need to set it
|
||||
},
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
local meshes = {}
|
||||
local function add_mesh(mesh, weights, add_neutral_bone)
|
||||
local attributes = {}
|
||||
|
||||
local vertices = mesh.vertices
|
||||
attributes.POSITION = add_accessor("VEC3", "float", false, function(write_byte)
|
||||
local inf = math.huge
|
||||
local min_pos, max_pos = {inf, inf, inf}, {-inf, -inf, -inf}
|
||||
for _, vertex in ipairs(mesh.vertices) do
|
||||
local pos = translation_to_gltf(vertex.pos)
|
||||
write_vector(write_byte, pos)
|
||||
min_pos = modlib.vector.combine(min_pos, pos, math.min)
|
||||
max_pos = modlib.vector.combine(max_pos, pos, math.max)
|
||||
end
|
||||
return #mesh.vertices, min_pos, max_pos -- vertex accessors MUST provide min & max
|
||||
end)
|
||||
|
||||
local has_normals = vertices.flags % 2 == 1 -- lowest bit set?
|
||||
if has_normals then
|
||||
attributes.NORMAL = add_accessor("VEC3", "float", false, function(write_byte)
|
||||
for _, vertex in ipairs(mesh.vertices) do
|
||||
-- Some B3D models don't seem to have their normals normalized.
|
||||
-- TODO (?) raise a warning when handling this gracefully
|
||||
write_translation(write_byte, modlib.vector.normalize(vertex.normal))
|
||||
end
|
||||
return #mesh.vertices
|
||||
end)
|
||||
end
|
||||
|
||||
local has_colors = vertices.flags % 4 >= 2 -- second lowest bit set?
|
||||
if has_colors then
|
||||
attributes.COLOR_0 = add_accessor("VEC4", "float", false, function(write_byte)
|
||||
for _, vertex in ipairs(mesh.vertices) do
|
||||
write_floats(write_byte, color_to_gltf(vertex.color), 4)
|
||||
end
|
||||
return #mesh.vertices
|
||||
end)
|
||||
end
|
||||
|
||||
if vertices.tex_coord_sets >= 1 then
|
||||
assert(vertices.tex_coord_set_size == 2)
|
||||
for tex_coord_set = 1, vertices.tex_coord_sets do
|
||||
local tcs_id = tex_coord_set - 1 -- 0-based
|
||||
attributes[("TEXCOORD_%d"):format(tcs_id)] = add_accessor("VEC2", "float", false, function(write_byte)
|
||||
for _, vertex in ipairs(mesh.vertices) do
|
||||
write_floats(write_byte, vertex.tex_coords[tex_coord_set], 2)
|
||||
end
|
||||
return #mesh.vertices
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
if next(weights) ~= nil then
|
||||
-- Count (& pack into list) joints influencing vertices, normalize weights
|
||||
local max_count = 0
|
||||
local joint_ids = {}
|
||||
local normalized_weights = {}
|
||||
-- Handle (supposedly) animated/dynamic vertices (can still be static by having zero weights)
|
||||
for vertex_id, joint_weights in pairs(weights) do
|
||||
local total_weight = 0
|
||||
local count = 0
|
||||
for _, weight in pairs(joint_weights) do
|
||||
total_weight = total_weight + weight
|
||||
count = count + 1
|
||||
end
|
||||
if total_weight > 0 then -- animated?
|
||||
joint_ids[vertex_id] = {}
|
||||
normalized_weights[vertex_id] = {}
|
||||
for joint, weight in pairs(joint_weights) do
|
||||
table.insert(joint_ids[vertex_id], joint)
|
||||
table.insert(normalized_weights[vertex_id], weight / total_weight)
|
||||
end
|
||||
max_count = math.max(max_count, count)
|
||||
end
|
||||
end
|
||||
-- Now search for static vertices
|
||||
for vertex_id in ipairs(mesh.vertices) do
|
||||
if not joint_ids[vertex_id] then
|
||||
-- Vertex isn't influenced by any bones => Add a dummy neutral bone to influence this vertex
|
||||
-- See https://github.com/KhronosGroup/glTF/issues/2269
|
||||
-- and https://github.com/KhronosGroup/glTF-Blender-IO/pull/1552/
|
||||
joint_ids[vertex_id] = {add_neutral_bone()}
|
||||
normalized_weights[vertex_id] = {1}
|
||||
max_count = math.max(max_count, 1) -- it is (theoretically) possible that all vertices are static
|
||||
end
|
||||
end
|
||||
assert(max_count > 0) -- TODO (?) warning for max_count > 4
|
||||
for set_start = 1, max_count, 4 do -- Iterate sets of 4 bones
|
||||
local set_id = math.floor(set_start / 4) -- 0-based => floor rather than ceil
|
||||
-- Write the joint IDs
|
||||
attributes[("JOINTS_%d"):format(set_id)] = add_accessor("VEC4", "unsigned_short", false, function(write_byte)
|
||||
for vertex_id in ipairs(mesh.vertices) do
|
||||
for i = set_start, set_start + 3 do
|
||||
local vrt_joint_ids, vrt_norm_weights = assert(joint_ids[vertex_id]), assert(normalized_weights[vertex_id])
|
||||
assert(#vrt_joint_ids == #vrt_norm_weights)
|
||||
local id = vrt_joint_ids[i] or 0
|
||||
local weight = vrt_norm_weights[i] or 0
|
||||
if weight == 0 then
|
||||
id = 0 -- required by the glTF spec
|
||||
end
|
||||
write_uint(write_byte, id, 2)
|
||||
end
|
||||
end
|
||||
return #mesh.vertices
|
||||
end)
|
||||
-- Write the corresponding weights
|
||||
attributes[("WEIGHTS_%d"):format(set_id)] = add_accessor("VEC4", "float", false, function(write_byte)
|
||||
for vertex_id in ipairs(mesh.vertices) do
|
||||
for i = set_start, set_start + 3 do
|
||||
local weight = (normalized_weights[vertex_id] or {})[i] or 0
|
||||
write_float(write_byte, weight)
|
||||
end
|
||||
end
|
||||
return #mesh.vertices
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
-- Write the indices per triangle set
|
||||
local primitives = {}
|
||||
for i, triangle_set in ipairs(mesh.triangle_sets) do
|
||||
local index_accessor = add_accessor("SCALAR", "unsigned_int", true, function(write_byte)
|
||||
for _, tri in ipairs(triangle_set.vertex_ids) do
|
||||
-- Flip winding order due to the coordinate system transformation
|
||||
-- TODO (!) is this correct?
|
||||
for j = 3, 1, -1 do
|
||||
write_index(write_byte, tri[j])
|
||||
end
|
||||
end
|
||||
return 3 * #triangle_set.vertex_ids
|
||||
end)
|
||||
-- Each triangle set is equivalent to one glTF "primitive"
|
||||
local brush_id = triangle_set.brush_id or mesh.brush_id
|
||||
if brush_id == 0 then -- default brush
|
||||
brush_id = nil -- TODO (?) add default material if there are UVs
|
||||
else
|
||||
brush_id = brush_id - 1 -- 0-based
|
||||
end
|
||||
primitives[i] = {
|
||||
attributes = attributes,
|
||||
indices = index_accessor,
|
||||
material = brush_id,
|
||||
-- `mode = 4` (triangles) is the default already, no need to set it
|
||||
}
|
||||
end
|
||||
|
||||
table.insert(meshes, {primitives = primitives})
|
||||
return #meshes - 1 -- 0-based
|
||||
end
|
||||
|
||||
-- glTF lists
|
||||
local nodes = {}
|
||||
local skins = {}
|
||||
local samplers = {}
|
||||
local channels = {}
|
||||
local function add_node(
|
||||
node, -- b3d node to add
|
||||
bind_mat, -- bind matrix of the parent bone (may be `nil` if none)
|
||||
fps, -- fps of the parent bone (may be `nil` if none)
|
||||
anim -- shared animation of the parent mesh
|
||||
)
|
||||
table.insert(nodes, false) -- HACK first insert a placeholder to get a fixed ID
|
||||
local node_id = #nodes - 1 -- 0-indexed <=> before `table.insert`!
|
||||
|
||||
-- Animation (speed)?
|
||||
fps = node.animation and node.animation.fps or fps
|
||||
|
||||
-- Keyframes?
|
||||
if node.keys then
|
||||
-- Convert from a list of keyframes of three overrides to three lists of channels
|
||||
local targets = {
|
||||
translation = {output_type = "VEC3", b3d_field = "position", write_value = write_translation},
|
||||
scale = {output_type = "VEC3", b3d_field = "scale", write_value = write_vector},
|
||||
rotation = {output_type = "VEC4", b3d_field = "rotation", write_value = write_quaternion}
|
||||
}
|
||||
for _, keyframe in ipairs(node.keys) do
|
||||
local frame = keyframe.frame
|
||||
for _, target in pairs(targets) do
|
||||
local value = keyframe[target.b3d_field]
|
||||
if value then
|
||||
table.insert(target, {frame = frame, value = value})
|
||||
end
|
||||
end
|
||||
end
|
||||
for target, keyframes in pairs(targets) do
|
||||
if #keyframes > 0 then
|
||||
-- Write input (timestamps)
|
||||
local input = add_accessor("SCALAR", "float", nil, function(write_byte)
|
||||
local min, max = math.huge, -math.huge
|
||||
for _, keyframe in ipairs(keyframes) do
|
||||
local sec = keyframe.frame / (fps or 60) -- convert frames to seconds; default FPS is 60
|
||||
write_float(write_byte, sec)
|
||||
min, max = math.min(min, sec), math.max(max, sec)
|
||||
end
|
||||
return #keyframes, {min}, {max} -- min and max are mandatory
|
||||
end)
|
||||
|
||||
-- Write output (overrides)
|
||||
local output = add_accessor(keyframes.output_type, "float", nil, function(write_byte)
|
||||
for _, keyframe in ipairs(keyframes) do
|
||||
keyframes.write_value(write_byte, keyframe.value)
|
||||
end
|
||||
return #keyframes
|
||||
end)
|
||||
|
||||
table.insert(samplers, {
|
||||
input = input,
|
||||
output = output,
|
||||
-- interpolation default is already linear, matching b3d
|
||||
})
|
||||
|
||||
table.insert(channels, {
|
||||
sampler = #samplers - 1, -- 0-based
|
||||
target = {
|
||||
node = node_id,
|
||||
path = target,
|
||||
}
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if node.mesh then
|
||||
-- Initialize skeletal animation
|
||||
assert(not anim)
|
||||
anim = {
|
||||
weights = {},
|
||||
joints = {},
|
||||
inv_bind_mats = {},
|
||||
}
|
||||
end
|
||||
|
||||
if node.bone then
|
||||
local joint_id = #anim.joints
|
||||
table.insert(anim.joints, node_id)
|
||||
|
||||
-- "To compose the local transformation matrix, TRS properties MUST be converted to matrices and postmultiplied in
|
||||
-- the T * R * S order; first the scale is applied to the vertices, then the rotation, and then the translation."
|
||||
local translation = translation_to_gltf(node.position)
|
||||
local rotation = modlib.quaternion.normalize(quaternion_to_gltf(node.rotation))
|
||||
local scale = node.scale
|
||||
local loc_trans_mat = mat4.scale(scale)
|
||||
:compose(mat4.rotation(rotation))
|
||||
:compose(mat4.translation(translation))
|
||||
|
||||
-- Compute a proper inverse bind matrix as the inverse of the product of the transformation matrices
|
||||
-- along the path from the root (the mesh) to the current node (the bone).
|
||||
-- See e.g. https://stackoverflow.com/questions/17127994/opengl-bone-animation-why-do-i-need-inverse-of-bind-pose-when-working-with-gp
|
||||
-- https://computergraphics.stackexchange.com/questions/7603/confusion-about-how-inverse-bind-pose-is-actually-calculated-and-used
|
||||
bind_mat = bind_mat and bind_mat:multiply(loc_trans_mat) or loc_trans_mat
|
||||
table.insert(anim.inv_bind_mats, bind_mat:inverse())
|
||||
|
||||
-- Insert into reverse lookup `anim.weights[vertex_id][joint_id] = weight`
|
||||
-- such that writing the mesh can then write the weights per vertex
|
||||
for vertex_id, weight in pairs(node.bone) do
|
||||
if weight > 0 then
|
||||
anim.weights[vertex_id] = anim.weights[vertex_id] or {}
|
||||
anim.weights[vertex_id][joint_id] = weight
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local children = {}
|
||||
for _, child in ipairs(node.children) do
|
||||
table.insert(children, add_node(child, bind_mat, fps, anim))
|
||||
end
|
||||
local mesh, skin_id, neutral_node_id
|
||||
if node.mesh then
|
||||
local neutral_joint_id
|
||||
-- Lazily adds a placeholder for the neutral joint, returns joint ID
|
||||
local function add_neutral_joint()
|
||||
if neutral_joint_id then
|
||||
return neutral_joint_id
|
||||
end
|
||||
neutral_node_id = #nodes -- 0-based
|
||||
table.insert(nodes, {
|
||||
name = "neutral_bone",
|
||||
-- We need to flip the hierarchy: The neutral bone must be a parent of the mesh root;
|
||||
-- if it were a sibling, there would be no common skeleton root (accepted by Blender but not by glTF validator);
|
||||
-- if it were a child, transformations of the mesh root would affect it and it wouldn't be a neutral bone anymore.
|
||||
children = {node_id},
|
||||
-- translation, scale, rotation all default to identity
|
||||
})
|
||||
neutral_joint_id = #anim.joints -- 0-based
|
||||
table.insert(anim.joints, neutral_node_id)
|
||||
return neutral_joint_id -- 0-based
|
||||
end
|
||||
mesh = add_mesh(node.mesh, anim.weights, add_neutral_joint)
|
||||
if anim.joints and anim.joints[1] then
|
||||
if neutral_joint_id then
|
||||
-- Duplicate the inverse bind matrix of the parent (which the neutral bone will be a child of)
|
||||
table.insert(anim.inv_bind_mats, bind_mat or mat4.identity())
|
||||
end
|
||||
table.insert(skins, {
|
||||
inverseBindMatrices = add_accessor("MAT4", "float", nil, function(write_byte)
|
||||
for _, inv_bind_mat in ipairs(anim.inv_bind_mats) do
|
||||
assert(#inv_bind_mat == 4)
|
||||
-- glTF uses column-major order (we use row-major order)
|
||||
for i = 1, 4 do
|
||||
for j = 1, 4 do
|
||||
write_float(write_byte, inv_bind_mat[j][i])
|
||||
end
|
||||
end
|
||||
end
|
||||
return #anim.inv_bind_mats
|
||||
end),
|
||||
joints = anim.joints,
|
||||
skeleton = neutral_node_id, -- make the neutral bone the skeleton root
|
||||
})
|
||||
skin_id = #skins - 1 -- 0-based
|
||||
end
|
||||
end
|
||||
-- Now replace the placeholder
|
||||
nodes[node_id + 1 --[[0-based to 1-based]]] = {
|
||||
name = node.name,
|
||||
mesh = mesh,
|
||||
skin = skin_id,
|
||||
children = children[1] and children, -- glTF does not allow empty lists
|
||||
translation = translation_to_gltf(node.position),
|
||||
scale = node.scale,
|
||||
rotation = quaternion_to_gltf(node.rotation),
|
||||
}
|
||||
-- If a neutral bone exists, return the neutral bone (which has the node as a child) instead of the node
|
||||
return neutral_node_id or node_id -- 0-based
|
||||
end
|
||||
|
||||
local scene, scenes
|
||||
if self.node then
|
||||
scene, scenes = 0, {{nodes = {add_node(self.node)}}}
|
||||
end
|
||||
|
||||
local buffer_string = table.concat(buffer_rope)
|
||||
return {
|
||||
asset = {
|
||||
generator = "modlib b3d:to_gltf",
|
||||
version = "2.0"
|
||||
},
|
||||
-- Textures
|
||||
textures = textures[1] and textures, -- glTF does not allow empty lists
|
||||
materials = materials[1] and materials,
|
||||
-- Accessors, buffer views & buffers
|
||||
accessors = accessors,
|
||||
bufferViews = buffer_views,
|
||||
buffers = {
|
||||
{
|
||||
byteLength = #buffer_string,
|
||||
uri = "data:application/octet-stream;base64,"
|
||||
.. modlib.base64.encode(buffer_string) -- Note: Blender requires base64 padding
|
||||
},
|
||||
},
|
||||
-- Meshes & nodes
|
||||
meshes = meshes,
|
||||
nodes = nodes,
|
||||
-- A scene is not strictly needed but is useful for getting rid of validator warnings & having a proper root defined
|
||||
scene = scene,
|
||||
scenes = scenes,
|
||||
-- Animation
|
||||
skins = skins,
|
||||
-- B3D only contains (up to) a single animation
|
||||
animations = channels[1] and {
|
||||
{
|
||||
channels = channels,
|
||||
samplers = samplers,
|
||||
},
|
||||
},
|
||||
}
|
||||
end
|
220
write_b3d.lua
Normal file
220
write_b3d.lua
Normal file
@ -0,0 +1,220 @@
|
||||
-- Writer
|
||||
local write_int, write_single = mtul.binary.write_int, mtul.binary.write_single
|
||||
local string_char = string.char
|
||||
|
||||
local function write_rope(self)
|
||||
local rope = {}
|
||||
|
||||
local written_len = 0
|
||||
local function write(str)
|
||||
written_len = written_len + #str
|
||||
table.insert(rope, str)
|
||||
end
|
||||
|
||||
local function byte(val)
|
||||
write(string_char(val))
|
||||
end
|
||||
|
||||
local function int(val)
|
||||
write_int(byte, val, 4)
|
||||
end
|
||||
|
||||
local function id(val)
|
||||
int(val - 1)
|
||||
end
|
||||
|
||||
local function optional_id(val)
|
||||
int(val and (val - 1) or -1)
|
||||
end
|
||||
|
||||
local function string(val)
|
||||
write(val)
|
||||
write"\0"
|
||||
end
|
||||
|
||||
local function float(val)
|
||||
write_single(byte, mtul.math.fround(val))
|
||||
end
|
||||
|
||||
local function float_array(arr, len)
|
||||
assert(#arr == len)
|
||||
for i = 1, len do
|
||||
float(arr[i])
|
||||
end
|
||||
end
|
||||
|
||||
local function color(val)
|
||||
float(val.r)
|
||||
float(val.g)
|
||||
float(val.b)
|
||||
float(val.a)
|
||||
end
|
||||
|
||||
local function vector3(val)
|
||||
float_array(val, 3)
|
||||
end
|
||||
|
||||
local function quaternion(quat)
|
||||
float(quat[4])
|
||||
float(quat[1])
|
||||
float(quat[2])
|
||||
float(quat[3])
|
||||
end
|
||||
|
||||
local function chunk(name, write_func)
|
||||
write(name)
|
||||
|
||||
-- Insert placeholder for the 4-bit len
|
||||
table.insert(rope, false)
|
||||
written_len = written_len + 4
|
||||
local len_idx = #rope -- save index of placeholder
|
||||
|
||||
local prev_written_len = written_len
|
||||
write_func()
|
||||
|
||||
-- Write the length of this chunk
|
||||
local chunk_len = written_len - prev_written_len
|
||||
local len_binary = {}
|
||||
write_int(function(byte)
|
||||
table.insert(len_binary, string_char(byte))
|
||||
end, chunk_len, 4)
|
||||
rope[len_idx] = table.concat(len_binary)
|
||||
end
|
||||
|
||||
local function NODE(node)
|
||||
chunk("NODE", function()
|
||||
string(node.name)
|
||||
vector3(node.position)
|
||||
vector3(node.scale)
|
||||
quaternion(node.rotation)
|
||||
local mesh = node.mesh
|
||||
if mesh then
|
||||
chunk("MESH", function()
|
||||
optional_id(mesh.brush_id)
|
||||
local vertices = mesh.vertices
|
||||
chunk("VRTS", function()
|
||||
int(vertices.flags)
|
||||
int(vertices.tex_coord_sets)
|
||||
int(vertices.tex_coord_set_size)
|
||||
for _, vertex in ipairs(vertices) do
|
||||
vector3(vertex.pos)
|
||||
if vertex.normal then vector3(vertex.normal) end
|
||||
if vertex.color then color(vertex.color) end
|
||||
for tex_coord_set = 1, vertices.tex_coord_sets do
|
||||
local tex_coords = vertex.tex_coords[tex_coord_set]
|
||||
for tex_coord = 1, vertices.tex_coord_set_size do
|
||||
float(tex_coords[tex_coord])
|
||||
end
|
||||
end
|
||||
end
|
||||
end)
|
||||
for _, triangle_set in ipairs(mesh.triangle_sets) do
|
||||
chunk("TRIS", function()
|
||||
id(triangle_set.brush_id)
|
||||
for _, tri in ipairs(triangle_set.vertex_ids) do
|
||||
id(tri[1])
|
||||
id(tri[2])
|
||||
id(tri[3])
|
||||
end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
end
|
||||
if node.bone then
|
||||
chunk("BONE", function()
|
||||
for vertex_id, weight in pairs(node.bone) do
|
||||
id(vertex_id)
|
||||
float(weight)
|
||||
end
|
||||
end)
|
||||
end
|
||||
if node.keys then
|
||||
local keys_by_flags = {}
|
||||
for _, key in ipairs(node.keys) do
|
||||
local flags = 0
|
||||
flags = flags
|
||||
+ (key.position and 1 or 0)
|
||||
+ (key.scale and 2 or 0)
|
||||
+ (key.rotation and 4 or 0)
|
||||
keys_by_flags[flags] = keys_by_flags[flags] or {}
|
||||
table.insert(keys_by_flags[flags], key)
|
||||
end
|
||||
for flags, keys in pairs(keys_by_flags) do
|
||||
chunk("KEYS", function()
|
||||
int(flags)
|
||||
for _, frame in ipairs(keys) do
|
||||
int(frame.frame)
|
||||
if frame.position then vector3(frame.position) end
|
||||
if frame.scale then vector3(frame.scale) end
|
||||
if frame.rotation then quaternion(frame.rotation) end
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
local anim = node.animation
|
||||
if anim then
|
||||
chunk("ANIM", function()
|
||||
int(anim.flags)
|
||||
int(anim.frames)
|
||||
float(anim.fps)
|
||||
end)
|
||||
end
|
||||
for _, child in ipairs(node.children) do
|
||||
NODE(child)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
chunk("BB3D", function()
|
||||
int(self.version.major * 100 + self.version.minor)
|
||||
if self.textures[1] then
|
||||
chunk("TEXS", function()
|
||||
for _, tex in ipairs(self.textures) do
|
||||
string(tex.file)
|
||||
int(tex.flags)
|
||||
int(tex.blend)
|
||||
float_array(tex.pos, 2)
|
||||
float_array(tex.scale, 2)
|
||||
float(tex.rotation)
|
||||
end
|
||||
end)
|
||||
end
|
||||
if self.brushes[1] then
|
||||
local max_n_texs = 0
|
||||
for _, brush in ipairs(self.brushes) do
|
||||
for n in pairs(brush.texture_id) do
|
||||
if n > max_n_texs then
|
||||
max_n_texs = n
|
||||
end
|
||||
end
|
||||
end
|
||||
chunk("BRUS", function()
|
||||
int(max_n_texs)
|
||||
for _, brush in ipairs(self.brushes) do
|
||||
string(brush.name)
|
||||
color(brush.color)
|
||||
float(brush.shininess)
|
||||
int(brush.blend)
|
||||
int(brush.fx)
|
||||
for index = 1, max_n_texs do
|
||||
optional_id(brush.texture_id[index])
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
if self.node then
|
||||
NODE(self.node)
|
||||
end
|
||||
end)
|
||||
return rope
|
||||
end
|
||||
|
||||
function mtul.b3d.write_string(self)
|
||||
return table.concat(write_rope(self))
|
||||
end
|
||||
|
||||
function mtul.b3d.write(self, stream)
|
||||
for _, str in ipairs(write_rope(self)) do
|
||||
stream:write(str)
|
||||
end
|
||||
end
|
Loading…
x
Reference in New Issue
Block a user