From 77ece0b9a697c4deb09ea078179ab90c8b7da8ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20Reuh=20Fildadut?= Date: Fri, 16 Sep 2022 20:01:07 +0900 Subject: [PATCH] Add gltf --- gltf/draw.can | 91 ++++++++++ gltf/gltf.can | 23 +++ gltf/init.lua | 1 + gltf/loader.can | 398 +++++++++++++++++++++++++++++++++++++++++ init.lua | 8 +- {ldtk => lib}/json.lua | 0 6 files changed, 519 insertions(+), 2 deletions(-) create mode 100644 gltf/draw.can create mode 100644 gltf/gltf.can create mode 100644 gltf/init.lua create mode 100644 gltf/loader.can rename {ldtk => lib}/json.lua (100%) diff --git a/gltf/draw.can b/gltf/draw.can new file mode 100644 index 0000000..d3e7b47 --- /dev/null +++ b/gltf/draw.can @@ -0,0 +1,91 @@ +--- drawing facilities for glTF models + +-- 2x2 white texture, used as a default for undefined textures in materials +let whiteTexture = love.graphics.newCanvas(2,2) +whiteTexture:renderTo(() + love.graphics.setColor(1,1,1) + love.graphics.rectangle("fill",0,0,2,2) +end) + +-- send a uniform to the shader if possible +let maybeSend = (s, name, val) + if s:hasUniform(name) then + s:send(name, val) + end +end +let maybeSendTexture = (s, name, tex) + if s:hasUniform(name) then + if tex then + s:send(name, tex.index.image) + else + s:send(name, whiteTexture) + end + end +end + +-- apply a material to the shader & LÖVE state +let applyMaterial = (s, mat) + maybeSend(s, "baseColorFactor", mat.pbrMetallicRoughness.baseColorFactor) + maybeSendTexture(s, "baseColorTexture", mat.pbrMetallicRoughness.baseColorTexture) + maybeSend(s, "metallicFactor", mat.pbrMetallicRoughness.metallicFactor) + maybeSend(s, "roughnessFactor", mat.pbrMetallicRoughness.roughnessFactor) + maybeSendTexture(s, "metallicRoughnessTexture", mat.pbrMetallicRoughness.metallicRoughnessTexture) + maybeSendTexture(s, "normalTexture", mat.normalTexture) + maybeSendTexture(s, "occlusionTexture", mat.occlusionTexture) + if mat.occlusionTexture then + maybeSend(s, "occlusionTextureStrength", mat.occlusionTexture.strength) + else + maybeSend(s, "occlusionTextureStrength", 1) + end + maybeSendTexture(s, "emissiveTexture", mat.emissiveTexture) + if mat.emissiveTexture then + maybeSend(s, "emissiveTextureScale", mat.emissiveTexture.scale) + else + maybeSend(s, "emissiveTextureScale", 1) + end + maybeSend(s, "emissiveFactor", mat.emissiveFactor) + if mat.alphaMode == "BLEND" then + love.graphics.setBlendMode("alpha") + maybeSend(s, "alphaCutoff", 0) + else + love.graphics.setBlendMode("replace") + maybeSend(s, "alphaCutoff", mat.alphaMode == "BLEND" and mat.alphaCutoff or 0) + end + if mat.doubleSided then + love.graphics.setMeshCullMode("none") + else + love.graphics.setMeshCullMode("back") + end +end + +-- draw a glTF node and its children +let drawNode = (node, s) + if node.mesh then + s:send("modelMatrix", "column", node.matrix) + for _, primitive in ipairs(node.mesh.primitives) do + applyMaterial(s, primitive.material) + love.graphics.draw(primitive.mesh) + end + end + for _, child in ipairs(node.children) do + drawNode(child, s) + end +end + +-- draw the main scene from glTF data +-- shader s is optional; will use current shder if not given +let drawMainScene = (gltf, s) + love.graphics.push("all") + love.graphics.setDepthMode("lequal", true) + if s then + love.graphics.setShader(s) + else + s = love.graphics.getShader() + end + for _, node in ipairs(gltf.scene.nodes) do + drawNode(node, s) + end + love.graphics.pop("all") +end + +return drawMainScene diff --git a/gltf/gltf.can b/gltf/gltf.can new file mode 100644 index 0000000..faa6a9e --- /dev/null +++ b/gltf/gltf.can @@ -0,0 +1,23 @@ +-- TODO: documentation + +let loader = require((...):gsub("gltf$", "loader")) +let draw = require((...):gsub("gltf$", "draw")) + +-- glTF object methods +let gltf_mt = { + -- loaded glTF data; see loader.can for details on its structure + gltf = nil, + -- draw the glTF object; if shader is not given, will use the current shader + -- see draw.can for the uniforms passed to the shader + draw = :(shader) + draw(@gltf, shader) + end +} +gltf_mt.__index = gltf_mt + +--- create new glTF object from a filepath +return function(path) + return setmetatable({ + gltf = loader(path) + }, gltf_mt) +end diff --git a/gltf/init.lua b/gltf/init.lua new file mode 100644 index 0000000..efba906 --- /dev/null +++ b/gltf/init.lua @@ -0,0 +1 @@ +return require((...)..".gltf") \ No newline at end of file diff --git a/gltf/loader.can b/gltf/loader.can new file mode 100644 index 0000000..f80dd7f --- /dev/null +++ b/gltf/loader.can @@ -0,0 +1,398 @@ +-- glTF 2.0 loader +-- see TODOs for missing features + +let json_decode +do + let r, json = pcall(require, "json") + if not r then json = require((...):gsub("gltf%.loader$", "lib.json")) end + json_decode = json.decode +end + +let cpml = require("cpml") +let mat4, vec3, quat = cpml.mat4, cpml.vec3, cpml.quat + +let dunpack = string.unpack or love.data.unpack + +--- Enums and the string that will be used to represent their values. +let attributeName = { + POSITION = "VertexPosition", + NORMAL = "VertexNormal", + TANGENT = "VertexTangent", + TEXCOORD_0 = "VertexTexCoord", + TEXCOORD_1 = "VertexTexCoord1", + COLOR_0 = "VertexColor", + JOINTS_0 = "VertexJoints", + WEIGHTS_0 = "VertexWeights" +} + +let componentType = { + [5120] = "byte", + [5121] = "unsigned byte", + [5122] = "short", + [5123] = "unsigned short", + [5125] = "int", + [5126] = "float" +} + +let samplerEnum = { + [9728] = "nearest", + [9729] = "linear", + [9984] = "nearest_mipmap_nearest", + [9985] = "linear_mipmap_nearest", + [9986] = "nearest_mipmap_linear", + [9987] = "linear_mipmap_linear", + [33071] = "clamp", + [33648] = "mirroredrepeat", + [10497] = "repeat" +} + +let mode = { + [0] = "points", + [1] = "lines", + [2] = "line_loop", + [3] = "line_strip", + [4] = "triangles", + [5] = "strip", + [6] = "fan" +} + +--- Load a glTF file and returns it. +-- The Lua table returned mirror the glTF structure, except: +-- * nodes, buffers, textures, etc. referenced using indices are replaced by an actual reference to the object +-- * node.matrix are replaced by corresponding mat4 objects from cpml and is calculated from TRS when undefined +-- * node.rotation, node.translation, node.scale replaced by quat and vec3 objects from cpml +-- * optional fields are intialized whith their standard default value (if any) +-- * enums number are replaced by the corresponding string (like accessor.componentType, primitive.mode) +-- if there is a LÖVE equivalent of this value, it is used (for example the wrap modes for textures); +-- otherwise the string is the same as the OpenGL name but in lowercase +-- new fields: +-- * camera.matrix: the projection matrix +-- * set a data field in buffers with the decoded/loaded data as a string +-- * set a image field in textures with the loaded LÖVE image +-- * set a data field in accessors as a list of components (either list of scalar or list of list) +-- * accessor.components contains component size +-- * the default material is created at materials[0] +-- * objects with name will have an associated field in list where they are present +-- This implementation will not perform data consistency checks and have absolute trust in the exporter. +let gltf = (path) + let f = assert(io.open(path, "r")) + let t = json_decode(f:read("*a")) + f:close() + + -- asset + if t.asset.minVersion then + let maj, min = t.asset.minVersion:match("^(%d+)%.(%d+)$") + assert(maj == "2" and min == "0", "asset require at least glTF version %s.%s but we only support 2.0":format(maj, min)) + else + let maj, min = t.asset.version:match("^(%d+)%.(%d+)$") + assert(maj == "2", "asset require glTF version %s.%s but we only support 2.x":format(maj, min)) + end + + -- empty lists + t.nodes or= {} + t.scenes or= {} + t.cameras or= {} + t.meshes or= {} + t.buffers or= {} + t.bufferViews or = {} + t.accessors or= {} + t.materials or= {} + t.textures or= {} + t.images or= {} + t.samplers or= {} + t.skins or= {} + t.animations or= {} + + -- scenes + for _, scene in ipairs(t.scenes) do + if scene.name then t.scenes[scene.name] = scene end + for i, node in ipairs(scene.nodes) do + scene.nodes[i] = t.nodes[node+1] + if scene.nodes[i].name then scene.nodes[scene.nodes[i].name] = scene.nodes[i] end + end + end + + -- scene + if t.scene then + t.scene = t.scenes[t.scene+1] + end + + -- nodes + for _, node in ipairs(t.nodes) do + if node.name then t.nodes[node.name] = node end + node.children or= {} + for i, child in ipairs(node.children) do + node.children[i] = t.nodes[child+1] + end + if node.matrix then + node.matrix = mat4(node.matrix) + else + node.translation or= {0,0,0} + node.rotation or= {0,0,0,1} + node.scale or= {1,1,1} + + node.translation = vec3(node.translation) + node.rotation = quat(node.rotation) + node.scale = vec3(node.scale) + + -- build a default transformation matrix from TRS + node.matrix = mat4.identity() + node.matrix:scale(node.matrix, node.scale) + node.matrix:mul(mat4.from_quaternion(node.rotation), node.matrix) + node.matrix:translate(node.matrix, node.translation) + end + if node.mesh then + node.mesh = t.meshes[node.mesh+1] + end + if node.camera then + node.camera = t.cameras[node.camera+1] + end + end + + -- buffers + for i, buffer in ipairs(t.buffers) do + if i == 1 and not buffer.uri then + error("no support for glb-stored buffer") -- TODO + end + if buffer.uri:match("data:") then + local data = buffer.uri:match("^data:.-,(.*)$") + if buffer.uri:match("^data:.-;base64,") then + buffer.data = love.data.decode("string", "base64", data):sub(1, buffer.byteLength+1) + else + buffer.data = data:gsub("%%(%x%x)", (hex) + return love.data.decode("string", "hex", hex) + end):sub(1, buffer.byteLength+1) + end + else + let bf = assert(io.open(buffer.uri, "r"), "can't find ressource %s":format(buffer.uri)) + let s = bf:read("*a") + bf:close() + buffer.data = s:sub(1, buffer.byteLength+1) + end + end + + -- bufferViews + for _, view in ipairs(t.bufferViews) do + view.buffer = t.buffers[view.buffer+1] + view.byteOffset or= 0 + -- TODO target + end + + -- accessors + for _, accessor in ipairs(t.accessors) do + accessor.bufferView = t.bufferViews[accessor.bufferView+1] + accessor.byteOffset or= 0 + + let view = accessor.bufferView + let data = view.buffer.data + + -- get component type and size + let fmt, size + accessor.componentType = componentType[accessor.componentType] + if accessor.componentType == "byte" then + fmt, size = "b", 1 + elseif accessor.componentType == "unsigned byte" then + fmt, size = "B", 1 + elseif accessor.componentType == "short" then + fmt, size = "h", 2 + elseif accessor.componentType == "unsigned short" then + fmt, size = "H", 2 + elseif accessor.componentType == "unsigned int" then + fmt, size = "I4", 4 + elseif accessor.componentType == "float" then + fmt, size = "f", 4 + end + + -- get element type and size + if accessor.type == "SCALAR" then + accessor.components, fmt = 1, fmt + elseif accessor.type == "VEC2" then + accessor.components, fmt = 2, fmt:rep(2) + elseif accessor.type == "VEC3" then + accessor.components, fmt = 3, fmt:rep(3) + elseif accessor.type == "VEC4" then + accessor.components, fmt = 4, fmt:rep(4) + elseif accessor.type == "MAT2" then + accessor.components = 4 + fmt = (fmt:rep(2) .. "x":rep(4 - (size*2)%4)):rep(2) -- padding at each column start + elseif accessor.type == "MAT3" then + accessor.components = 9 + fmt = (fmt:rep(3) .. "x":rep(4 - (size*3)%4)):rep(3) + elseif accessor.type == "MAT4" then + accessor.components = 16 + fmt = (fmt:rep(4) .. "x":rep(4 - (size*4)%4)):rep(4) + end + + fmt =.. "<" -- little endian + + -- extract elements from raw data + accessor.data = {} + let i = view.byteOffset+1 + accessor.byteOffset + let stop = view.byteOffset+1 + view.byteLength + let count = 0 + while i < stop and count < accessor.count do + local d = { dunpack(fmt, data, i) } + d[#d] = nil + if accessor.components > 1 then + table.insert(accessor.data, d) + else + table.insert(accessor.data, d[1]) + end + count += 1 + i += view.byteStride or (size * accessor.components) + end + + -- TODO sparse accessor + end + + -- images + for _, image in ipairs(t.images) do + if image.uri then + image.image = love.graphics.newImage(image.uri) + else + image.bufferView = t.bufferViews[image.bufferView+1] + + let view = image.bufferView + let data = view.buffer.data + + image.data = love.image.newImageData(love.data.newByteData(data:sub(view.byteOffset+1, view.byteOffset+view.byteLength))) + end + end + -- samplers + for _, sampler in ipairs(t.samplers) do + sampler.wrapS or= 10497 + sampler.wrapT or= 10497 + + sampler.magFilter = samplerEnum[sampler.magFilter] + sampler.minFilter = samplerEnum[sampler.minFilter] + sampler.wrapS = samplerEnum[sampler.wrapS] + sampler.wrapT = samplerEnum[sampler.wrapT] + end + -- textures + for _, texture in ipairs(t.textures) do + texture.source = t.images[texture.source+1] or {} + texture.sampler = t.samplers[texture.sampler+1] + -- make LÖVE image + let mag = texture.sampler.magFilter + let min = texture.sampler.minFilter + let mip + if min:match("_mipmap_") then + min, mip = min:match("^(.*)_mipmap_(.*)$") -- use mipmap; get the filtering used for both mipmaps and min + end + texture.image = love.graphics.newImage(texture.source.data, { mipmaps = not not mip }) + texture.image:setFilter(min or "linear", mag) + if mip then texture.image:setMipmapFilter(mip) end + texture.image:setWrap(texture.sampler.wrapS, texture.sampler.wrapT) + end + + -- default material + t.materials[0] = { + pbrMetallicRoughness = { + baseColorFactor = {1,1,1,1}, + metallicFactor = 1, + roughnessFactor = 1 + }, + emissiveFactor = {0,0,0}, + alphaMode = "OPAQUE", + alphaCutoff = .5, + doubleSided = false + } + -- materials + for _, material in ipairs(t.materials) do + material.pbrMetallicRoughness or= {} + material.pbrMetallicRoughness.baseColorFactor or= {1,1,1,1} + if material.pbrMetallicRoughness.baseColorTexture then + material.pbrMetallicRoughness.baseColorTexture.index = t.textures[material.pbrMetallicRoughness.baseColorTexture.index+1] + material.pbrMetallicRoughness.baseColorTexture.texCoord or= 0 + end + material.pbrMetallicRoughness.metallicFactor or= 1 + material.pbrMetallicRoughness.roughnessFactor or= 1 + if material.pbrMetallicRoughness.metallicRoughnessTexture then + material.pbrMetallicRoughness.metallicRoughnessTexture.index = t.textures[material.pbrMetallicRoughness.metallicRoughnessTexture.index+1] + material.pbrMetallicRoughness.metallicRoughnessTexture.texCoord or= 0 + end + if material.normalTexture then + material.normalTexture.index = t.textures[material.normalTexture.index+1] + material.normalTexture.texCoord or= 0 + material.normalTexture.scale or= 1 + end + if material.occlusionTexture then + material.occlusionTexture.index = t.textures[material.occlusionTexture.index+1] + material.occlusionTexture.texCoord or= 0 + material.occlusionTexture.strength or= 1 + end + if material.emissiveTexture then + material.emissiveTexture.index = t.textures[material.emissiveTexture.index+1] + material.emissiveTexture.texCoord or= 0 + end + material.emissiveFactor or= {0,0,0} + material.alphaMode or= "OPAQUE" + material.alphaCutoff or= .5 + material.doubleSided or= false + end + + -- meshes + for _, mesh in ipairs(t.meshes) do + for _, primitive in ipairs(mesh.primitives) do + let vertexformat = {} + let vertices = {} + for n, v in pairs(primitive.attributes) do + let accessor = t.accessors[v+1] + primitive.attributes[n] = accessor + table.insert(vertexformat, { attributeName[n] or n, accessor.componentType, accessor.components }) + for i, x in ipairs(accessor.data) do + let vertex = vertices[i] + if not vertex then + table.insert(vertices, i, {}) + vertex = vertices[i] + end + for _, c in ipairs(x) do + table.insert(vertex, c) + end + end + end + + if primitive.mode then + primitive.mode = mode[primitive.mode] + else + primitive.mode = "triangles" + end + + primitive.mesh = love.graphics.newMesh(vertexformat, vertices, primitive.mode) + if primitive.indices then + primitive.indices = [ for _, i in ipairs(t.accessors[primitive.indices+1].data) do i+1 end ] + primitive.mesh:setVertexMap(primitive.indices) + end + + primitive.material = t.materials[(primitive.material or -1)+1] + if primitive.material.pbrMetallicRoughness.baseColorTexture then + primitive.mesh:setTexture(primitive.material.pbrMetallicRoughness.baseColorTexture.index.image) + end + + -- TODO targets + end + end + + -- cameras + for _, camera in ipairs(t.cameras) do + if camera.name then t.cameras[camera.name] = camera end + if camera.type == "perspective" then + camera.perspective.aspectRatio or= 16/9 + camera.matrix = mat4.from_perspective(camera.perspective.yfov, camera.perspective.aspectRatio, camera.perspective.znear, camera.perspective.zfar) + elseif camera.type == "orthographic" then + camera.matrix = mat4.from_ortho(0, 0, camera.orthographic.xmag, camera.orthographic.ymag, camera.orthographic.znear, camera.orthographic.zfar) + end + end + + -- TODO skins + + -- TODO animations + + -- TODO extensions + + -- TODO glb + + return t +end + +return gltf diff --git a/init.lua b/init.lua index 3031d82..7ec352d 100644 --- a/init.lua +++ b/init.lua @@ -62,12 +62,16 @@ ubiquitousse = { -- @see ecs ecs = nil, --- Input management, if available. - -- TODO: not currently generated with LDoc. + -- TODO: documentation not currently generated with LDoc. -- @see input input = nil, --- LDtk level import, if available. -- @see ldtk ldtk = nil, + --- glTF model import, if available. + -- TODO: documentation not currently generated with LDoc. + -- @see gltf + gltf = nil, --- Scene management, if available. -- @see scene scene = nil, @@ -100,7 +104,7 @@ end package.loaded[p] = ubiquitousse -- Require external submodules -for _, m in ipairs{"signal", "asset", "ecs", "input", "scene", "timer", "util", "ldtk"} do +for _, m in ipairs{"signal", "asset", "ecs", "input", "scene", "timer", "util", "ldtk", "gltf"} do local s, t = pcall(require, p.."."..m) if s then ubiquitousse[m] = t diff --git a/ldtk/json.lua b/lib/json.lua similarity index 100% rename from ldtk/json.lua rename to lib/json.lua