# Blender 3.0 (headless): load level.obj from geom_export (v x y z r g b, vt, usemtl page_NN/flat_N), # render in game coordinates (Blender x = game x, y = -game z, z = game y). # blender -b -P render_level.py -- OBJDIR OUTPREFIX [samples] [scale%] import bpy, sys, os, json, math from mathutils import Vector from bpy_extras.object_utils import world_to_camera_view argv = sys.argv[sys.argv.index('--') + 1:] objdir, outp = argv[0], argv[1] samples = int(argv[2]) if len(argv) > 2 else 64 scale = int(argv[3]) if len(argv) > 3 else 100 bpy.ops.wm.read_factory_settings(use_empty=True) sc = bpy.context.scene # --------------------------------------------------------------- MTL mats = {} cur = None for ln in open(os.path.join(objdir, 'level.mtl')): p = ln.split() if not p: continue if p[0] == 'newmtl': cur = p[1]; mats[cur] = {} elif p[0] == 'Kd': mats[cur]['kd'] = tuple(float(v) for v in p[1:4]) elif p[0] == 'map_Kd': mats[cur]['img'] = p[1] def make_mat(name, d): m = bpy.data.materials.new(name) m.use_nodes = True nt = m.node_tree; N = nt.nodes; Lk = nt.links N.clear() out = N.new('ShaderNodeOutputMaterial') bsdf = N.new('ShaderNodeBsdfPrincipled') bsdf.inputs['Roughness'].default_value = 0.9 bsdf.inputs['Specular'].default_value = 0.15 vc = N.new('ShaderNodeVertexColor'); vc.layer_name = 'Col' mul = N.new('ShaderNodeMixRGB'); mul.blend_type = 'MULTIPLY'; mul.inputs[0].default_value = 1.0 Lk.new(vc.outputs['Color'], mul.inputs[2]) if 'img' in d: tex = N.new('ShaderNodeTexImage') tex.image = bpy.data.images.load(os.path.join(objdir, d['img'])) tex.interpolation = 'Closest' Lk.new(tex.outputs['Color'], mul.inputs[1]) Lk.new(tex.outputs['Alpha'], bsdf.inputs['Alpha']) m.blend_method = 'CLIP' else: kd = d.get('kd', (1, 1, 1)) mul.inputs[1].default_value = (*kd, 1) if kd[2] > 0.5 and kd[0] < 0.3: # sea bsdf.inputs['Roughness'].default_value = 0.25 bsdf.inputs['Specular'].default_value = 0.5 # slightly emissive so the baked lighting (vertex colours) stays visible Lk.new(mul.outputs[0], bsdf.inputs['Base Color']) Lk.new(mul.outputs[0], bsdf.inputs['Emission']) bsdf.inputs['Emission Strength'].default_value = 0.25 Lk.new(bsdf.outputs[0], out.inputs[0]) return m # --------------------------------------------------------------- OBJ V, C, VT = [], [], [] faces = [] # (vidx, vtidx, mat) mat = None for ln in open(os.path.join(objdir, 'level.obj')): if ln.startswith('v '): p = ln.split() x, y, z = float(p[1]), float(p[2]), float(p[3]) V.append((x, -z, y)) # OBJ = game space (right-handed, y up) -> Blender z up C.append((float(p[4]), float(p[5]), float(p[6])) if len(p) >= 7 else (1, 1, 1)) elif ln.startswith('vt '): p = ln.split(); VT.append((float(p[1]), float(p[2]))) elif ln.startswith('usemtl'): mat = ln.split()[1] elif ln.startswith('f '): vi, ti = [], [] for t in ln.split()[1:]: q = t.split('/') vi.append(int(q[0]) - 1) ti.append(int(q[1]) - 1 if len(q) > 1 and q[1] else -1) faces.append((vi, ti, mat)) matnames = sorted(set(f[2] for f in faces)) mesh = bpy.data.meshes.new('level') mesh.from_pydata(V, [], [f[0] for f in faces]) for n in matnames: mesh.materials.append(make_mat(n, mats.get(n, {}))) mi = {n: i for i, n in enumerate(matnames)} uvl = mesh.uv_layers.new(name='UV') col = mesh.vertex_colors.new(name='Col') for poly, (vi, ti, m) in zip(mesh.polygons, faces): poly.material_index = mi[m] for k, li in enumerate(poly.loop_indices): t = ti[k] uvl.data[li].uv = VT[t] if t >= 0 else (0, 0) c = C[vi[k]] col.data[li].color = (c[0], c[1], c[2], 1) mesh.update() ob = bpy.data.objects.new('level', mesh) sc.collection.objects.link(ob) # --------------------------------------------------------------- object markers (strats from strats.json) def flatmat(name, rgb, emit=0.6): m = bpy.data.materials.new(name); m.use_nodes = True b = m.node_tree.nodes['Principled BSDF'] b.inputs['Base Color'].default_value = (*rgb, 1) b.inputs['Emission'].default_value = (*rgb, 1); b.inputs['Emission Strength'].default_value = emit b.inputs['Roughness'].default_value = 0.4 return m sj = os.environ.get('STRATS') if sj and os.path.exists(sj): ROWZ = [22.5, 25.2, 27.9, 30.6] rowcol = [(1.0, 0.8, 0.1), (0.75, 0.75, 0.78), (0.2, 0.45, 1.0), (0.95, 0.15, 0.12)] M = {k: flatmat(k, c) for k, c in [('coin', (1.0, 0.75, 0.05)), ('lama', (1.0, 0.3, 0.05)), ('cp', (1.0, 0.55, 0.0)), ('tuer', (0.75, 0.2, 1.0)), ('tier', (0.95, 0.95, 0.9))]} RM = [flatmat('row%d' % i, c) for i, c in enumerate(rowcol)] for d in json.load(open(sj)): n, x, y, z = d['name'], d['x'], d['y'], d['z'] loc = (x, -z, y) if n == 'Coin' and not any(abs(z - r) < 0.3 for r in ROWZ): bpy.ops.mesh.primitive_cylinder_add(radius=0.18, depth=0.05, location=(x, -z, y + 0.35), rotation=(math.radians(90), 0, math.radians(90))) mat = M['coin'] elif n == 'Llama': bpy.ops.mesh.primitive_cone_add(radius1=0.3, depth=0.9, location=(x, -z, y + 0.45)); mat = M['lama'] elif n == 'CheckPointPost' and not any(abs(z - r) < 0.3 for r in ROWZ): bpy.ops.mesh.primitive_cylinder_add(radius=0.1, depth=1.2, location=(x, -z, y + 0.6)); mat = M['cp'] elif n == 'LevelDoor': bpy.ops.mesh.primitive_torus_add(major_radius=0.4, minor_radius=0.07, location=(x, -z, y + 0.6), rotation=(0, math.radians(90), 0)); mat = M['tuer'] elif n in ('Bunny', 'Ladybird', 'FlockOfBirds', 'VillagerHerder'): bpy.ops.mesh.primitive_uv_sphere_add(radius=0.25, location=(x, -z, y + 0.3)); mat = M['tier'] else: r = min(range(4), key=lambda i: abs(z - ROWZ[i])) bpy.ops.mesh.primitive_ico_sphere_add(radius=0.22, subdivisions=1, location=(x, -z, y + 0.4)) mat = RM[r] o = bpy.context.active_object; o.data.materials.append(mat) # --------------------------------------------------------------- light, world w = bpy.data.worlds.new('w'); sc.world = w; w.use_nodes = True bg = w.node_tree.nodes['Background'] bg.inputs[0].default_value = (0.55, 0.72, 0.95, 1); bg.inputs[1].default_value = 0.9 sun = bpy.data.lights.new('sun', 'SUN'); sun.energy = 2.6; sun.angle = math.radians(6) so = bpy.data.objects.new('sun', sun); sc.collection.objects.link(so) so.rotation_euler = (math.radians(42), 0, math.radians(-140)) # Cycles renders only black with Debian Blender 3.0.1 -> Eevee (needs X: a separate Xvfb, e.g. :7) sc.render.engine = 'BLENDER_EEVEE' ee = sc.eevee ee.taa_render_samples = samples ee.use_gtao = True; ee.gtao_distance = 1.5 ee.use_soft_shadows = True ee.shadow_cascade_size = '4096' sun.shadow_cascade_max_distance = 120 sun.shadow_cascade_count = 4 try: sc.view_settings.view_transform = 'Filmic' sc.view_settings.look = 'Medium High Contrast' except TypeError: pass # Debian Blender without OCIO config: only 'Standard' # areas (game coordinates x, z, y) for the labels; keys are the label text (German, as in the level) LABELS = {'Start': (18, 15.5, 2.2), 'Galerie': (23.5, 26.5, 1.2), 'Gehege': (37.5, 25.4, 1.2), 'Parkour': (33.0, 15.5, 2.6), 'Ziel': (50.0, 15.5, 6.0)} def shoot(name, loc, look, lens=35, ortho=None, res=(1600, 1000)): cam = bpy.data.cameras.new(name) co = bpy.data.objects.new(name, cam); sc.collection.objects.link(co) co.location = loc d = Vector(look) - Vector(loc) co.rotation_euler = d.to_track_quat('-Z', 'Y').to_euler() if ortho: cam.type = 'ORTHO'; cam.ortho_scale = ortho else: cam.lens = lens cam.clip_end = 500 sc.camera = co sc.render.resolution_x, sc.render.resolution_y = res sc.render.resolution_percentage = scale sc.render.filepath = outp + '_' + name + '.png' bpy.ops.render.render(write_still=True) lab = {} for k, (x, z, y) in LABELS.items(): v = world_to_camera_view(sc, co, Vector((x, -z, y))) lab[k] = (v.x, 1 - v.y) json.dump(lab, open(outp + '_' + name + '.json', 'w')) views = os.environ.get('VIEWS', 'schraeg,oben').split(',') if 'schraeg' in views: # 'schraeg' = oblique: from the south-west, above shoot('schraeg', (10, -50, 27), (30.5, -21.5, 0), lens=33) if 'schraeg2' in views: # from the north-east (behind the goal) shoot('schraeg2', (60, 2, 22), (29, -21, 0), lens=30) if 'oben' in views: # 'oben' = top view shoot('oben', (31, -21.5, 80), (31, -21.4, 0), ortho=44)