# The contents of this file are subject to the Common Public Attribution License # Version 1.0 (the “License”); you may not use this file except in compliance # with the License. You may obtain a copy of the License at # https://myou.dev/licenses/LICENSE-CPAL. The License is based on the Mozilla # Public License Version 1.1 but Sections 14 and 15 have been added to cover use # of software over a computer network and provide for limited attribution for # the Original Developer. In addition, Exhibit A has been modified to be # consistent with Exhibit B. # # Software distributed under the License is distributed on an “AS IS” basis, # WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for # the specific language governing rights and limitations under the License. # # The Original Code is Myou Engine. # # the Original Developer is the Initial Developer. # # The Initial Developer of the Original Code is the Myou Engine developers. # All portions of the code written by the Myou Engine developers are Copyright # (c) 2024. All Rights Reserved. # # Alternatively, the contents of this file may be used under the terms of the # GNU Affero General Public License version 3 (the [AGPL-3] License), in which # case the provisions of [AGPL-3] License are applicable instead of those above. # # If you wish to allow use of your version of this file only under the terms of # the [AGPL-3] License and not to allow others to use your version of this file # under the CPAL, indicate your decision by deleting the provisions above and # replace them with the notice and other provisions required by the [AGPL-3] # License. If you do not delete the provisions above, a recipient may use your # version of this file under either the CPAL or the [AGPL-3] License. {.warning[UseBase]: off.} # can't seem to satisy this... {.warning[UnusedImport]: off.} # for ./loader_base {.warning[rsemMethodLockMismatch]: off.} # TODO: is this important? const myouUseBlendLoader {.booldefine.} = true import ../types import ./loader_base export loader_base import std/tables import std/strutils import std/strformat import std/bitops import std/options import std/json import std/os import vmath except Quat, quat import ../quat import ../util import ../../libs/loadable/loadable import ../graphics/material import ../graphics/render import ../graphics/texture import ../incomplete import ../myou_engine import ../objects/armature import ../objects/camera import ../objects/cubemap_probe import ../objects/gameobject import ../objects/light import ../objects/mesh import ../scene import ./blend_format import ./blend_mesh import ./blend_nodes import float16 # import sugar when defined(nimdoc): type TYPES* = BlendLoader proc newBlendLoader*(engine: MyouEngine, shader_library: string = "", shader_textures = initTable[string, Texture]()): BlendLoader = result = new BlendLoader result.engine = engine if shader_library != "": result.shader_library = shader_library result.shader_textures = shader_textures else: result.shader_library = get_builtin_shader_library() result.shader_textures = get_builtin_shader_textures() result.use_indices = true proc registerBlendLoader*(engine: MyouEngine) = when myouUseBlendLoader: engine.registerLoader(@["blend"], proc(e: MyouEngine): Loader = e.newBlendLoader() ) method close*(self: BlendLoader) = if self.resource != nil: self.resource = nil self.blend_file = nil self.cached_materials.clear() self.textures.clear() method openAssetFile*(self: BlendLoader, path: string) = if self.resource != nil: self.close() # self.on_destroy = OnDestroy(destructor: proc() = self.close()) self.blend_file_path = path proc loadAsync(self: BlendLoader, callback: proc(err: string)) = if self.blend_file != nil: callback("") else: self.close() self.resource = loadUri(self.blend_file_path, proc(res: auto) = self.resource = nil if res.ok: self.blend_file = openBlendFile(self.blend_file_path, res.data.data, res.data.byte_len) callback(res.err) ) type BlenderObTypes = enum BEmpty = 0 BMesh = 1 BText = 4 BLight = 10 BCamera = 11 BProbe = 13 BArmature = 25 BCurve = 27 type BlenderRotationOrder* = enum BAxisAngle = -1 BQuaternion BEulerXYZ BEulerXZY BEulerYXZ BEulerYZX BEulerZXY BEulerZYX # template vec2(p: ptr array[16, float32]): Vec2 = cast[ptr Vec2](p)[] template vec3(p: ptr array[16, float32]): Vec3 = cast[ptr Vec3](p)[] template vec4(p: ptr array[16, float32]): Vec4 = cast[ptr Vec4](p)[] template mat4(p: ptr array[16, float32]): Mat4 = cast[ptr Mat4](p)[] proc abs_path*(self: BlendLoader, path: string): string = if path.len >= 2 and path[0 .. 1] == "//": # TODO: japanese path separator? does it appear ever? let dir = self.blend_file_path.rsplit({'/','\\'},1)[0] if dir == "": return path[2 ..< ^0] return dir & path[1 ..< ^0] return path proc getTextBlockLines*(self: BlendLoader, name: string): seq[string] = for txt in self.blend_file.named_blocks["TX"]: if txt.id.name.str.strip2 == name: var line = txt.lines.first while line.valid: result.add line.line.str line = line.next return raise KeyError.newException "Text block not found: " & name proc getNodeTree(self: BlendLoader, name: string): FNode = for tree in self.blend_file.named_blocks["NT"]: if tree.id.name.str.strip2 == name: return tree method loadTextureImpl*(self: BlendLoader, name: string, img: FNode): Texture = var file_path = self.abs_path(img.name.str) let color_space = img.colorspace_settings.name.str let is_packed = img.packedfile.valid let source = img.source.i16[0] case source: of 1: # file let is_sRGB = color_space == "sRGB" # echo img if is_packed: let blend_name = self.blend_file_path.extractFilename let cache_key = blend_name & "/Images/" & name # echo "loading image as PACKED file" # todo: was this necessary? # let seek = img.packedfile.seek.i32[0] let size = img.packedfile.size.i32[0] let arr = img.packedfile.data.get_array(size, byte) # TODO: get SliceMems from blend file instead of UncheckedArray let s = SliceMem[byte](data: arr, byte_len: size) return self.engine.newTexture(name, s, is_sRGB, cache_key = cache_key) else: # echo "loading image as REGULAR file" if self.path_handler != nil: file_path = self.path_handler(file_path) try: return self.engine.newTexture(name, file_path, is_sRGB) except OSError: raise newException(OSError, "could not open file: " & file_path) # of 2: # image sequence # of 3: # movie of 4: # generated let tile = img.tiles.first let gen_type = tile.gen_type.i8[0] # 0: blank, 1: uv grid, 2: color grid if gen_type != 0: echo "Warning: Image generated type other than 'Blank' is not supported yet" echo " ", name let (r,g,b,a) = tile.gen_color.f32.vec4.toTuple let c16 = newArrRef[array[4, Float16]](1) c16[0] = [r.tofloat16,g.tofloat16,b.tofloat16,a.tofloat16] return self.engine.newTexture(name,1,1,1,RGBA_f16,pixels=c16.to float32) # of 5: # UDIM sequence else: assert false, &"Image source not supported yet: {source}, image '{name}'" method loadTexture*(self: BlendLoader, name: string): Texture = if name in self.textures: return self.textures[name] for img in self.blend_file.named_blocks["IM"]: let nm = img.id.name.str.strip2 if name == nm: let tex = self.loadTextureImpl(name, img) self.textures[name] = tex return tex raise newException(KeyError, "Can't find image " & name) proc makeMaterialAndTextures(self: BlendLoader; bmat: FNode, shader_library=self.shader_library, can_be_double_sided=false, ): (string, seq[Varying], OrderedTable[string, Texture]) = try: var textures: OrderedTable[string, Texture] let (fragment, varyings, texs, tex_pixels) = self.makeMaterial( bmat, @[ shader_library, self.engine.tone_mapping_library, ].join("\n"), MakeMaterialSettings( can_be_double_sided: can_be_double_sided, tone_mapping_function: self.engine.tone_mapping_function, uniform_blocks: get_render_uniform_blocks(), textures_sampler_type: self.override_textures_sampler_type, get_library: proc(name: string): seq[string] = self.getTextBlockLines(name), get_node_tree: proc(name: string): FNode = self.getNodeTree(name), ) ) for name,uname in texs: try: let tex = if name in tex_pixels: let tp = tex_pixels[name] # TODO: filtering? sampler? let t = self.engine.newTexture(name, tp.width, tp.height, 1, RGBA_f32, pixels=tp.pixels) t.setExtrapolation Clamp t else: self.loadTexture(name) textures[uname] = tex except KeyError: echo "can't load texture " & name for name,tex in self.shader_textures: textures[name] = tex return (fragment, varyings, textures) except Exception as e: echo "Material: ", bmat.id.name.str.strip2 raise e proc idPropertiesToJsonTable(prop: FNode): Table[string, JsonNode] = var prop = prop while prop.valid: let name = prop.name.str let val = prop.data.val.i32[0] var cstr: cstring = "" if prop.data["pointer"].valid: var p = prop.data["pointer"] p.set_type("char") cstr = p.cstr case prop["type"].i8[0] of 0: # string result[name] = %($cstr) of 1: # int result[name] = %val of 5: # array # let subtype = prop.subtype.i8[0] discard of 8: # float let f = cast[ptr float64](prop.data.val.i32[0].addr)[] result[name] = %f of 10: # bool result[name] = %bool(val) else: discard prop = prop.next proc loadFCurveImpl(self: BlendLoader, fcurve: FNode): (string, AnimationChannel) = let path = fcurve.rna_path.str let chan = new AnimationChannel let path_parts = path.rsplit('.',1) if path_parts.len == 1: chan.channel_type = ChObject else: if path.startswith "key_blocks[": chan.channel_type = ChShape chan.name = path.split('"')[1] elif path.startswith "pose.bones[": return # TODO else: echo "Warning: unknown channel path ", path return chan.property = case path_parts[^1]: of "value": PropValue of "location": PropPosition of "rotation_euler": PropRotationEuler of "rotation_quaternion": PropRotationQuaternion of "scale": PropScale else: echo "Warning: unknown property ", path return chan.index = fcurve.array_index.i32[0] if chan.property == PropRotationQuaternion: chan.index = (chan.index - 1) and 3 let count = fcurve.totvert.i32[0] for i,floats in fcurve.bezt[0 ..< count].vec: let fs = floats.f32 chan.points.add BezierPoint( # left_handle: vec2(fs[0], fs[1]), co: vec2(fs[3], fs[4]), # right_handle: vec2(fs[6], fs[7]), ) if chan.property != PropValue: return (&"{path}[{chan.index}]", chan) else: return (path, chan) proc loadActionImpl(self: BlendLoader, acn: FNode): Action = new(result) let flags = acn.flag.i32[0] result.manual_range = (flags and 4096).bool result.frame_start = acn.frame_start.f32[0] result.frame_end = acn.frame_end.f32[0] var curve = acn.curves.first while curve.valid: let (path, ch) = self.loadFCurveImpl(curve) if ch != nil: result.channels[path] = ch curve = curve.next proc loadAction*(self: BlendLoader, name: string): Action = for n in self.blend_file.named_blocks["AC"]: if n.id.name.str.strip2 == name: return self.loadActionImpl(n) raise KeyError.newException &"Could not find action '{name}'" proc loadObjectImpl(self: BlendLoader, scene: Scene, obn: FNode): (GameObject, string) = let name = obn.id.name.str.strip2 let data = obn.data var shape_key_adt: FNode let ob = case obn["type"].i16[0]: of BMesh.int16: var ob = self.engine.new_mesh(name=name) let mat_count = obn.totcol.i32[0] # echo " has materials ", mat_count # echo " data materials ", obn.data.totcol.i16[0] let matbits = obn.matbits.i8 ob.passes.setLen mat_count var tangents: seq[string] for i in 0 ..< mat_count: assert i < 16, "More than 16 materials not supported yet" # TODO let is_oblink = matbits[i].testBit(0) let mat = if is_oblink: obn.mat[i] else: data.mat[i] if not mat.valid: ob.materials.add nil continue var backface_culling = (mat.blend_flag.i8[0] and 4'i8) != 0 let blend_method = mat.blend_method.i8[0] let alpha_blend = blend_method == 5 if alpha_blend: ob.passes[i] = 1 # this gives the "sons" error # dump (mat.id.name.cstr.strip2, backface_culling) # # find textures # var node = mat.nodetree.nodes.first # while node.valid: # # dump node # if node.idname.cstr == "ShaderNodeTexImage": # let name = node.id.name.str.strip2 # try: # let tex = self.loadTexture(name) # let uname = "samp" & $textures.len # textures.add TextureUniform(name: uname, texture: tex) # except KeyError: # echo "can't load texture " & name # node = node.next let (fragment, varyings, textures) = self.makeMaterialAndTextures( mat, shader_library = @[scene.get_lighting_code, self.shader_library].join("\n"), can_be_double_sided = not backface_culling, ) # dump varyings # TODO: use existing materials with same name! let mat_name = mat.id.name.str.strip2 ob.materials.add self.engine.newMaterial( "mat" & mat_name & $i, scene, # fragment = """ # precision highp float; # uniform float time; # in vec4 vcolor; # in vec3 varnormal; # out vec4 outColor; # void main(){ # outColor = vec4(varnormal, 1.0); # } # """, # varyings = @[ # Varying(vtype: VertexColor, varname: "vcolor", multiplier: 1/255), # Varying(vtype: WorldNormal, varname: "varnormal", multiplier: 1/255), # ], fragment = fragment, varyings = varyings, textures = textures, ubos = scene.get_lighting_UBOs, double_sided = not backface_culling, ) for v in varyings: if v.vtype == Tangent and v.attname notin tangents: tangents.add v.attname if data.key.valid: shape_key_adt = data.key.adt # TODO: defer this by storing the name of the mesh as hash # (maybe the address too) self.loadMeshImpl(obn, data, ob, required_tangents = tangents) # dump ob.layout # for mat in ob.materials: # dump mat.get_shader(ob).vs_code ob.GameObject # of BText.int16: # var ob = self.engine.new_text(name=name) # ob.GameObject of BLight.int16: let light_type = case data["type"].i16[0]: of 0: PointLight of 1: SunLight of 2: SpotLight of 4: AreaLight else: echo "Error: unknown light type: " & $data["type"].i16[0] PointLight let color = vec3(data.r.f32[0], data.g.f32[0], data.b.f32[0]) let mode = data.mode.i32[0] let use_shadow = mode.testBit(0) let use_dist = mode.testBit(20) let cutoff = if use_dist: data.att_dist.f32[0] else: 0'f32 var ob = self.engine.new_light( name = name, light_type = light_type, color = color, energy = data.energy.f32[0], spot_size = data.spotsize.f32[0], spot_blend = data.spotblend.f32[0], use_shadow = use_shadow, cutoff_distance = cutoff, diffuse_factor = data.diff_fac.f32[0], specular_factor = data.spec_fac.f32[0], light_radius = data.radius.f32[0], ) # dump obn ob.GameObject of BCamera.int16: # dump data let sensor_fit: SensorFit = case data.sensor_fit.i8[0]: of 0: Auto of 1: Horizontal of 2: Vertical else: echo &"Unknown sensor fit: {data.sensor_fit.i8[0]}" Auto let sensor_size = if sensor_fit == Vertical: # vertical data.sensor_y.f32[0] else: # horizontal or auto data.sensor_x.f32[0] let focal_length = data.lens.f32[0] let field_of_view = 2 * arctan(sensor_size / (2 * focal_length)) var ob = self.engine.new_camera(name=name, near_plane = data.clipsta.f32[0], far_plane = data.clipend.f32[0], field_of_view = field_of_view, ortho_scale = data.ortho_scale.f32[0], # TODO: get actual aspect ratio of screen if this is the active camera aspect_ratio = 1, cam_type = if data["type"].i8[0] == 0: Perspective else: Orthographic, sensor_fit = sensor_fit, shift = vec2(data.shiftx.f32[0], data.shifty.f32[0]), ) ob.GameObject of BArmature.int16: var ob = self.engine.new_armature(name=name) ob.GameObject of BProbe.int16: let flag = data.flag.i8[0] let attenuation_type = data.attenuation_type.i8[0].ProbeInfluenceType let influence_distance = data.distinf.f32[0] let custom_parallax = (flag and 1).bool let custom_parallax_dist = data.distpar.f32[0] let (parallax_type, parallax_dist) = if custom_parallax: if custom_parallax_dist > 1e8: (NoParallax, custom_parallax_dist) else: (data.parallax_type.i8[0].ProbeParallaxType, custom_parallax_dist) else: (attenuation_type.int8.ProbeParallaxType, influence_distance) var ob = case data["type"].i8[0]: of 0: var ob = self.engine.new_cubemap_probe(name=name, influence_type = attenuation_type, influence_distance = influence_distance, falloff = data.falloff.f32[0], intensity = data.intensity.f32[0], clipping_start = data.clipsta.f32[0], clipping_end = data.clipend.f32[0], parallax_type = parallax_type, parallax_distance = parallax_dist, ) ob.GameObject # of 1: # var ob = self.engine.new_planar_probe(name=name) # ob.GameObject else: echo "Unknown probe type: ", obn.data["type"].i8[0] var ob = self.engine.new_gameobject(name=name) ob ob else: var ob = self.engine.new_gameobject(name=name) ob let parent_name = (obn.parent.id.name?.str).strip2 # TODO parent bone ob.position = obn.loc.f32.vec3 ob.matrix_parent_inverse = obn.parentinv.f32.mat4 ob.rotation_order = case obn.rotmode.i16[0].BlenderRotationOrder: of BAxisAngle: AxisAngle of BQuaternion: Quaternion of BEulerXYZ: EulerXYZ of BEulerXZY: EulerXZY of BEulerYXZ: EulerYXZ of BEulerYZX: EulerYZX of BEulerZXY: EulerZXY of BEulerZYX: EulerZYX ob.rotation = if ob.rotation_order == Quaternion: let q = obn.quat.f32 Rotation(quat: quat(q[1], q[2], q[3], q[0])) elif ob.rotation_order == AxisAngle: assert false, "not implemented" Rotation() else: let r = obn.rot.f32 Rotation(euler: vec3(r[0], r[1], r[2])) ob.scale = obn.size.f32.vec3 let restrictflag = obn.restrictflag.i16[0] ob.visible = (restrictflag and 4) == 0 ob.object_color = obn.col.f32.vec4 var animation_datas: seq[FNode] for adt in [obn.adt, shape_key_adt]: if adt.valid: animation_datas.add adt # TODO: make animation strips. we'll just merge the actions for now. for adt in animation_datas: if adt.action.valid: let ac = self.loadActionImpl(adt.action) if ob.action == nil: ob.action = ac else: for k,v in ac.channels: ob.action.channels[k] = v let prop = obn.id.properties if prop.valid: ob.properties = idPropertiesToJsonTable(prop.data.group.first) return (ob, parent_name) proc loadSceneImpl(self: BlendLoader, scn: FNode, scene: Scene) = let active_camera_name = (scn.camera.id.name?.str).strip2 # scene.world.gravity = scn.physics_settings.gravity.f32.vec3 let ren = scn.r scene.anim_fps = ren.frs_sec.i16[0].float / ren.frs_sec_base.f32[0] scene.frame_start = ren.sfra.i32[0] scene.frame_end = ren.efra.i32[0] let world = scn.world var r,g,b = 0'f32 if world.valid: # TODO: srgb? better in the renderer? r = world.horr.f32[0] g = world.horg.f32[0] b = world.horb.f32[0] scene.background_color = vec4(r, g, b, 1) # note: there's always nodes by default now let use_nodes = world.valid and world.use_nodes.i16[0] != 0 if use_nodes: # TODO: use background color instead of material when there's just a plain color let (fragment, varyings, textures) = self.makeMaterialAndTextures(world, "", false) let mat_name = world.id.name.str.strip2 scene.world_material = self.engine.newMaterial( "world_" & mat_name, scene, fragment = fragment, varyings = varyings, textures = textures, double_sided = false, ) scene.world_material.fixed_z = some(1.0) scene.cubemap_resolution = scn.eevee.gi_cubemap_resolution.i32[0] # TODO: bg probe # TODO: shadow cascade/cube sizes var objects: OrderedTable[string, (GameObject, string)] var nodes: seq[FNode] var base = scn.view_layers.first.object_bases.first if base.isNil: base = scn.base.first while base.valid: let obn = base.object let name = obn.id.name.str[2..^1] # echo name # let ob_type = ob["type"].i16[0] # if ob_type == 10: # let data = ob.data # if data.isNil: continue # # echo data # let mode = data.mode.i32[0] # let use_shadow = mode.testBit(0) # dump use_shadow objects[name] = self.loadObjectImpl(scene, obn) nodes.add obn base = base.next var update_light_counts = scene.world_material != nil # assign parents and active camera var i = 0 for k,(ob, pn) in objects: scene.add_object ob if active_camera_name == k and ob.is_camera: scene.set_active_camera ob.get_camera # # TODO: remove this when implemented in Viewport # ob.get_camera.aspect_ratio = self.engine.width / self.engine.height # ob.get_camera.update_projection() if ob.is_light or ob.is_cubemap_probe: update_light_counts = true if pn != "": # dump (k, pn) # TODO: all this parenting stuff is a mess, # doing the same things elsewhere ob.parent = objects[pn][0] objects[pn][0].children.add ob # if ob.is_mesh: # dump (k, nodes[i].mat[0].id.name.str, nodes[i].data.mat[0].id.name.str) # echo nodes[i] i += 1 scene.reorder_children() if update_light_counts: scene.calculate_max_lights_and_cubemaps() proc get_active_scene_name(self: BlendLoader): string = let win = self.blend_file.named_blocks["WM"][0].winactive if win.valid: let scene = win.scene if scene.valid: return scene.id.name.str[2 .. ^1] method loadScene*(self: BlendLoader, name: string="", scene: Scene=nil, callback: proc(err: string, scene: Scene)) = assert self.blend_file_path != "", "Blend file is not loaded" self.loadAsync proc(err: string) = if err != "": callback(err, nil) return assert self.blend_file != nil, "Error loading blend file " & self.blend_file_path var name = name if name == "": name = self.get_active_scene_name() let idname = "SC" & name for scn in self.blend_file.named_blocks["SC"]: if scn["id"]["name"].cstr == idname.cstring: let node = scn var scene = scene let was_first_scene = self.engine.scenes.len == 0 if scene == nil: scene = self.engine.new_scene(name=name) try: self.loadSceneImpl(node, scene) except Exception as e: for line in e.getStackTrace.split '\n': echo line echo getCurrentExceptionMsg() scene.destroy() callback(getCurrentExceptionMsg(), nil) return callback("", scene) if self.engine.new_del_scenes.getOrDefault(scene.name).isNil: # it was deleted return # TODO: when loading is async, move this stuff after loading has # finished if was_first_scene and not scene.enabled: echo "Warning: Your scene is not enabled, use 'scene.enable_render()' or 'scene.enable_all()'" self.engine.renderer.enqueue proc() = scene.render_all_cubemaps(true) return assert false, &"Scene {name} not found" proc debug_dump*(self: BlendLoader) = self.blend_file.debug_dump