"""LEVEL 0's inhabitants. /Applications/Blender.app/Contents/MacOS/Blender --background \ --python tools/gen_horror_props.py -- [--render] THE ENTITY is deliberately ambiguous. It's 2.5 m, far too thin, has no face, arms that reach past its knees, and a smooth cranium swept backwards — which reads as Slenderman from the front and as something else entirely in profile. Keeping it unresolved is the point: a silhouette your brain finishes is worse than a monster it recognises. Origin at floor-centre, facing -Z (so `look_at` and yaw work the obvious way). THE TOTEM is the Blair Witch stick figure — twigs lashed into a person — which exists to be found hanging in a corridor you have definitely already walked down. """ import bpy import math import os import sys from mathutils import Vector, Matrix OUT_DIR = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "game", "assets", "store") PREVIEW_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "previews") RENDER = "--render" in sys.argv def reset(): bpy.ops.wm.read_factory_settings(use_empty=True) def mat(name, color, rough=0.6, metal=0.0): m = bpy.data.materials.new(name) m.use_nodes = True b = m.node_tree.nodes["Principled BSDF"] b.inputs["Base Color"].default_value = (color[0], color[1], color[2], 1.0) b.inputs["Roughness"].default_value = rough b.inputs["Metallic"].default_value = metal return m def assign(o, m): o.data.materials.clear() o.data.materials.append(m) return o def box(size, loc=(0, 0, 0), rot=(0, 0, 0)): bpy.ops.mesh.primitive_cube_add(size=1.0, location=loc, rotation=rot) o = bpy.context.active_object o.scale = (size[0], size[1], size[2]) bpy.ops.object.transform_apply(scale=True) return o def cyl(r, depth, loc=(0, 0, 0), rot=(0, 0, 0), verts=14, r2=None): if r2 is None: bpy.ops.mesh.primitive_cylinder_add(radius=r, depth=depth, vertices=verts, location=loc, rotation=rot) else: bpy.ops.mesh.primitive_cone_add(radius1=r, radius2=r2, depth=depth, vertices=verts, location=loc, rotation=rot) return bpy.context.active_object def ball(r, loc=(0, 0, 0), segs=16, rings=10): bpy.ops.mesh.primitive_uv_sphere_add(radius=r, segments=segs, ring_count=rings, location=loc) return bpy.context.active_object def bevel(o, width=0.006, segments=2): m = o.modifiers.new("bevel", "BEVEL") m.width = width m.segments = segments m.limit_method = 'ANGLE' m.angle_limit = math.radians(40) bpy.context.view_layer.objects.active = o try: bpy.ops.object.modifier_apply(modifier=m.name) except Exception: o.modifiers.remove(m) return o def shade_smooth(o, angle=40.0): bpy.context.view_layer.objects.active = o o.select_set(True) try: bpy.ops.object.shade_auto_smooth(angle=math.radians(angle)) except Exception: bpy.ops.object.shade_smooth() o.select_set(False) def span(r0, r1, p0, p1, verts=8): p0, p1 = Vector(p0), Vector(p1) d = p1 - p0 n = d.length if n < 1e-6: return None o = cyl(r0, n, verts=verts, r2=r1) quat = Vector((0, 0, 1)).rotation_difference(d.normalized()) o.matrix_world = Matrix.Translation(p0 + d * 0.5) @ quat.to_matrix().to_4x4() bpy.ops.object.transform_apply(location=True, rotation=True) return o # ---------------------------------------------------------------- the entity def p_entity(M): """2.5 m, far too thin, no face. Faces -Y in Blender, which the glTF export turns into -Z — the direction Godot's look_at treats as forward.""" parts = [] H = 2.50 # legs: absurdly long, barely tapering, no feet to speak of for s in (-1, 1): hip = Vector((s * 0.085, 0.0, 1.18)) knee = Vector((s * 0.10, 0.02, 0.62)) ankle = Vector((s * 0.095, -0.01, 0.045)) parts.append(assign(span(0.062, 0.052, hip, knee), M["suit"])) parts.append(assign(span(0.052, 0.040, knee, ankle), M["suit"])) foot = box((0.10, 0.24, 0.045), loc=(s * 0.095, -0.05, 0.022)) parts.append(assign(bevel(foot, 0.014, 2), M["suit"])) # torso: a narrow slab, wider at the shoulders than anything human torso = box((0.40, 0.20, 0.78), loc=(0.0, 0.0, 1.56)) parts.append(assign(bevel(torso, 0.055, 3), M["suit"])) hips = box((0.26, 0.17, 0.22), loc=(0.0, 0.0, 1.20)) parts.append(assign(bevel(hips, 0.045, 3), M["suit"])) # lapels, so it reads as a suit and not a mannequin for s in (-1, 1): lap = box((0.10, 0.03, 0.46), loc=(s * 0.085, -0.10, 1.68), rot=(0, math.radians(s * 5), 0)) parts.append(assign(lap, M["shirt"])) tie = box((0.05, 0.02, 0.40), loc=(0.0, -0.108, 1.62)) parts.append(assign(tie, M["tie"])) # arms: they reach past the knees. This is the tell. for s in (-1, 1): sh = Vector((s * 0.205, 0.0, 1.90)) elb = Vector((s * 0.255, 0.03, 1.24)) wr = Vector((s * 0.235, 0.01, 0.58)) parts.append(assign(span(0.058, 0.044, sh, elb), M["suit"])) parts.append(assign(span(0.044, 0.034, elb, wr), M["suit"])) # long pale fingers for f in range(4): a = wr + Vector((s * 0.006 * (f - 1.5), -0.012, -0.02)) b = a + Vector((s * 0.012 * (f - 1.5), -0.03, -0.20 - 0.02 * abs(f - 1.5))) parts.append(assign(span(0.011, 0.006, a, b, verts=6), M["skin"])) parts.append(assign(ball(0.038, loc=tuple(wr), segs=12, rings=8), M["skin"])) # head: smooth, blank, and swept BACKWARDS — Slenderman from the front, something # with a cranium from the side. Never resolving which is the whole idea. neck = span(0.052, 0.060, Vector((0, 0, 1.93)), Vector((0, 0.01, 2.10))) parts.append(assign(neck, M["skin"])) skull = ball(0.115, loc=(0.0, 0.03, 2.24), segs=24, rings=16) skull.scale = (0.86, 1.30, 1.02) bpy.ops.object.transform_apply(scale=True) parts.append(assign(skull, M["skin"])) back = ball(0.095, loc=(0.0, 0.14, 2.27), segs=20, rings=14) back.scale = (0.78, 1.55, 0.86) bpy.ops.object.transform_apply(scale=True) parts.append(assign(back, M["skin"])) return parts # ---------------------------------------------------------------- the totem def p_totem(M): """Twigs lashed into a person. Hangs from a doorway you have already walked through.""" parts = [] # the cord it hangs by parts.append(assign(span(0.006, 0.006, Vector((0, 0, 1.10)), Vector((0, 0, 0.86))), M["cord"])) # body + limbs, all crooked on purpose parts.append(assign(span(0.015, 0.013, Vector((0, 0, 0.86)), Vector((0.01, 0, 0.40))), M["twig"])) parts.append(assign(span(0.013, 0.011, Vector((-0.20, 0.02, 0.78)), Vector((0.21, -0.02, 0.72))), M["twig"])) for s in (-1, 1): parts.append(assign(span(0.012, 0.009, Vector((0.01, 0, 0.42)), Vector((s * 0.16, 0.03 * s, 0.06))), M["twig"])) # head: a bundle for i in range(5): a = i * 1.1 parts.append(assign(span(0.008, 0.008, Vector((math.cos(a) * 0.05, math.sin(a) * 0.04, 0.90)), Vector((-math.cos(a) * 0.05, -math.sin(a) * 0.04, 0.80))), M["twig"])) # lashings for z in (0.86, 0.75, 0.42): r = cyl(0.026, 0.014, loc=(0.005, 0, z), verts=10) parts.append(assign(r, M["cord"])) return parts PROPS = {"entity": p_entity, "totem": p_totem} def materials(): return { # near-black, but not pure — pure black reads as a hole, and a hole is less # frightening than a thing "suit": mat("suit", (0.045, 0.045, 0.055), rough=0.72), "shirt": mat("shirt", (0.80, 0.79, 0.76), rough=0.85), "tie": mat("tie", (0.10, 0.09, 0.11), rough=0.6), "skin": mat("skin", (0.90, 0.89, 0.86), rough=0.42), "twig": mat("twig", (0.32, 0.24, 0.15), rough=0.95), "cord": mat("cord", (0.58, 0.52, 0.38), rough=1.0), } def export(objs, name): objs = [o for o in objs if o is not None] bpy.ops.object.select_all(action='DESELECT') for o in objs: o.select_set(True) bpy.context.view_layer.objects.active = objs[0] path = os.path.join(OUT_DIR, name + ".glb") bpy.ops.export_scene.gltf(filepath=path, export_format='GLB', use_selection=True, export_apply=True, export_yup=True) print("[horror] %-12s -> %s" % (name, os.path.basename(path))) bpy.ops.object.select_all(action='DESELECT') def render_preview(name): scene = bpy.context.scene engines = scene.render.bl_rna.properties['engine'].enum_items.keys() for want in ('BLENDER_EEVEE_NEXT', 'BLENDER_EEVEE', 'BLENDER_WORKBENCH'): if want in engines: scene.render.engine = want break scene.render.resolution_x = 560 scene.render.resolution_y = 760 if scene.world is None: scene.world = bpy.data.worlds.new("preview") scene.world.use_nodes = True bg = scene.world.node_tree.nodes.get("Background") if bg: bg.inputs[0].default_value = (0.44, 0.42, 0.22, 1.0) # backrooms yellow lo = Vector((1e9,) * 3) hi = Vector((-1e9,) * 3) for o in scene.objects: if o.type != 'MESH': continue for c in o.bound_box: w = o.matrix_world @ Vector(c) lo = Vector((min(lo.x, w.x), min(lo.y, w.y), min(lo.z, w.z))) hi = Vector((max(hi.x, w.x), max(hi.y, w.y), max(hi.z, w.z))) centre = (lo + hi) * 0.5 radius = max((hi - lo).length * 0.5, 0.05) dist = radius * 2.5 d = Vector((0.42, -0.88, 0.10)).normalized() bpy.ops.object.camera_add(location=tuple(centre + d * dist)) cam = bpy.context.active_object cam.data.lens = 62 cam.rotation_mode = 'QUATERNION' cam.rotation_quaternion = (-d).to_track_quat('-Z', 'Y') scene.camera = cam for off, e, s in ((Vector((0.7, -1.0, 0.9)), 42.0, 1.4), (Vector((-1.0, -0.3, 0.2)), 12.0, 2.0)): bpy.ops.object.light_add(type='AREA', location=tuple(centre + off * dist)) L = bpy.context.active_object L.data.energy = e * (dist ** 2) L.data.size = s * radius scene.render.filepath = os.path.join(PREVIEW_DIR, name + ".png") bpy.ops.render.render(write_still=True) def main(): os.makedirs(OUT_DIR, exist_ok=True) os.makedirs(PREVIEW_DIR, exist_ok=True) for name, builder in PROPS.items(): reset() M = materials() parts = builder(M) for p in parts: shade_smooth(p, 40.0) export(parts, name) if RENDER: render_preview(name) print("[horror] done -> %s" % OUT_DIR) main()