"""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 def p_entity2(M): """THE OTHER ONE. Same height, same darkness, same suit — at thirty metres in a yellow room you cannot tell it from the first one, and that is the entire point, because its rules are inverted and guessing wrong kills you. Up close there are tells, and they're all wrong in the same direction: the head is tilted fully back so it is looking at the ceiling and not at you, the arms are raised above it, and the feet hang — Backrooms.gd floats it, so it never touches the carpet.""" parts = [] # legs hang straight and limp; no knee break, no weight on them for s in (-1, 1): hip = Vector((s * 0.075, 0.0, 1.16)) ankle = Vector((s * 0.080, 0.03, 0.10)) parts.append(assign(span(0.055, 0.036, hip, ankle), M["suit"])) foot = box((0.085, 0.20, 0.038), loc=(s * 0.080, 0.02, 0.085), rot=(math.radians(28), 0, 0)) parts.append(assign(bevel(foot, 0.012, 2), M["suit"])) torso = box((0.36, 0.19, 0.80), loc=(0.0, 0.0, 1.56)) parts.append(assign(bevel(torso, 0.055, 3), M["suit"])) hips = box((0.24, 0.16, 0.20), loc=(0.0, 0.0, 1.20)) parts.append(assign(bevel(hips, 0.045, 3), M["suit"])) for s in (-1, 1): lap = box((0.09, 0.03, 0.44), loc=(s * 0.078, -0.095, 1.68), rot=(0, math.radians(s * 5), 0)) parts.append(assign(lap, M["shirt"])) # arms UP, not down — reaching for the ceiling it is already staring at for s in (-1, 1): sh = Vector((s * 0.190, 0.0, 1.92)) elb = Vector((s * 0.290, 0.02, 2.32)) wr = Vector((s * 0.250, 0.01, 2.76)) parts.append(assign(span(0.054, 0.042, sh, elb), M["suit"])) parts.append(assign(span(0.042, 0.032, elb, wr), M["suit"])) for f in range(4): a = wr + Vector((s * 0.006 * (f - 1.5), -0.008, 0.015)) b = a + Vector((s * 0.014 * (f - 1.5), -0.02, 0.19 + 0.02 * abs(f - 1.5))) parts.append(assign(span(0.010, 0.006, a, b, verts=6), M["skin"])) parts.append(assign(ball(0.035, loc=tuple(wr), segs=12, rings=8), M["skin"])) # head thrown back: the throat is what faces you neck = span(0.050, 0.058, Vector((0, 0, 1.94)), Vector((0, -0.09, 2.14))) parts.append(assign(neck, M["skin"])) skull = ball(0.112, loc=(0.0, -0.12, 2.22), segs=24, rings=16) skull.scale = (0.84, 1.02, 1.34) bpy.ops.object.transform_apply(scale=True) parts.append(assign(skull, M["skin"])) back = ball(0.090, loc=(0.0, -0.02, 2.30), segs=20, rings=14) back.scale = (0.76, 0.90, 1.50) bpy.ops.object.transform_apply(scale=True) parts.append(assign(back, M["skin"])) return parts PROPS = {"entity": p_entity, "entity2": p_entity2, "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()