"""Rebuild the original woodland cast and meadow tuft with Blender 5.1+. Blender --background --python tools/art/build_storybook_assets.py Editable .blend sources are outside Godot's import tree. Runtime GLBs contain vertex colors, one material and eight rigid animation parts per character. Coordinates below use Godot's Y-up, +Z-facing convention; export converts it. """ import json import math import random from pathlib import Path import bpy from mathutils import Vector ROOT = Path(__file__).resolve().parents[2] SOURCE = ROOT / "art/storybook" OUTPUT = ROOT / "assets/storybook" OUTPUT.mkdir(parents=True, exist_ok=True) SOURCE.mkdir(parents=True, exist_ok=True) INK = "303733" CREAM = "f4e5bf" PINK = "c98678" LEATHER = "654933" GOLD = "d9aa54" PIVOTS = { "Body": (0, 1.05, 0), "Head": (0, 1.68, 0), "Hair": (0, 1.9, 0), "ArmLeft": (-0.39, 1.30, 0), "ArmRight": (0.39, 1.30, 0), "LegLeft": (-0.18, 0.61, 0), "LegRight": (0.18, 0.61, 0), "Tail": (0, 0.8, -0.22), } PARTS = {} STATS = {} def coord(v): return Vector((v[0], -v[2], v[1])) def linear(hex_color): values = [int(hex_color[i:i + 2], 16) / 255 for i in (0, 2, 4)] return tuple(v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4 for v in values) def paint(obj, color, part, cloth=False): attr = obj.data.color_attributes.new(name="Color", type="FLOAT_COLOR", domain="CORNER") rgb = linear(color) for entry in attr.data: entry.color = (*rgb, 1.0 if cloth else 0.0) obj.data.materials.append(bpy.data.materials["StorybookPalette"]) for poly in obj.data.polygons: poly.use_smooth = True PARTS.setdefault(part, []).append(obj) return obj def ellipsoid(part, center, radius, color, cloth=False, tilt=0, segments=16, rings=10): bpy.ops.mesh.primitive_uv_sphere_add(segments=segments, ring_count=rings, location=coord(center)) obj = bpy.context.object obj.scale = (radius[0], radius[2], radius[1]) obj.rotation_euler.y = math.radians(tilt) return paint(obj, color, part, cloth) def tube(part, points, radius, color, cloth=False, sides=8): verts, faces = [], [] for i, point in enumerate(points): p = coord(point) tangent = coord(points[min(i + 1, len(points) - 1)]) - coord(points[max(0, i - 1)]) tangent.normalize() side = tangent.cross(Vector((0, 1, 0))) if side.length < 0.01: side = tangent.cross(Vector((1, 0, 0))) side.normalize() other = tangent.cross(side).normalized() r = radius[i] if isinstance(radius, list) else radius for j in range(sides): a = math.tau * j / sides verts.append(p + r * (math.cos(a) * side + math.sin(a) * other)) if i: for j in range(sides): a, b = (i - 1) * sides + j, (i - 1) * sides + (j + 1) % sides faces.append((a, b, b + sides, a + sides)) faces.extend([tuple(reversed(range(sides))), tuple(range(len(verts) - sides, len(verts)))]) mesh = bpy.data.meshes.new(part + "Detail") mesh.from_pydata(verts, [], faces) mesh.update() obj = bpy.data.objects.new(part + "Detail", mesh) bpy.context.collection.objects.link(obj) return paint(obj, color, part, cloth) def leaf_ear(part, x, bottom, height, width, fur, tilt): ellipsoid(part, (x, bottom + height * 0.46, -0.025), (width, height * 0.55, width * 0.57), fur, tilt=tilt) ellipsoid(part, (x, bottom + height * 0.48, width * 0.48), (width * 0.58, height * 0.39, 0.024), PINK, tilt=tilt, segments=12, rings=8) def reset(): bpy.ops.object.select_all(action="SELECT") bpy.ops.object.delete(use_global=False) PARTS.clear() material = bpy.data.materials.get("StorybookPalette") or bpy.data.materials.new("StorybookPalette") material.use_nodes = True tree = material.node_tree tree.nodes.clear() vertex = tree.nodes.new("ShaderNodeVertexColor") vertex.layer_name = "Color" shader = tree.nodes.new("ShaderNodeBsdfPrincipled") shader.inputs["Roughness"].default_value = 0.88 tree.links.new(vertex.outputs["Color"], shader.inputs["Base Color"]) output = tree.nodes.new("ShaderNodeOutputMaterial") tree.links.new(shader.outputs["BSDF"], output.inputs["Surface"]) def finish(name): objects = [] for part, pieces in PARTS.items(): bpy.ops.object.select_all(action="DESELECT") for obj in pieces: obj.select_set(True) bpy.context.view_layer.objects.active = pieces[0] bpy.ops.object.join() obj = bpy.context.object obj.name = part bpy.ops.object.transform_apply(location=False, rotation=True, scale=True) bpy.context.scene.cursor.location = coord(PIVOTS.get(part, (0, 0, 0))) bpy.ops.object.origin_set(type="ORIGIN_CURSOR") # Join left duplicate palette slots; one surface per animated part. obj.data.materials.clear() obj.data.materials.append(bpy.data.materials["StorybookPalette"]) for polygon in obj.data.polygons: polygon.material_index = 0 obj.data.calc_loop_triangles() objects.append(obj) bpy.ops.object.select_all(action="SELECT") bpy.ops.export_scene.gltf( filepath=str(OUTPUT / (name + ".glb")), export_format="GLB", use_selection=True, export_yup=True, export_animations=False, export_vertex_color="ACTIVE", export_all_vertex_colors=False, export_materials="EXPORT", export_extras=False, export_cameras=False, ) bpy.context.scene.cursor.location = (0, 0, 0) # A useful material-colored editing view on reopening the source. for screen in bpy.data.screens: for area in screen.areas: if area.type == "VIEW_3D": area.spaces.active.shading.type = "MATERIAL" bpy.context.preferences.filepaths.save_version = 0 bpy.ops.wm.save_as_mainfile(filepath=str(SOURCE / (name + ".blend"))) STATS[name] = {"triangles": sum(len(o.data.loop_triangles) for o in objects), "surfaces": len(objects), "bytes": (OUTPUT / (name + ".glb")).stat().st_size} def character(species, fur, coat, scarf): reset() stout = 1.13 if species == "badger" else (0.90 if species == "rabbit" else 1.0) # Pear-shaped coat, soft collar, pockets, rolled hems and hand-sewn buttons. ellipsoid("Body", (0, 0.98, 0), (0.40 * stout, 0.50, 0.29), coat, True) ellipsoid("Body", (0, 1.25, 0), (0.30 * stout, 0.28, 0.23), coat, True) tube("Body", [(-0.22, 1.40, 0.14), (0, 1.34, 0.245), (0.23, 1.40, 0.13)], 0.065, scarf) ellipsoid("Body", (0.17, 1.23, 0.287), (0.085, 0.22, 0.025), scarf, tilt=-12) for y in (0.83, 1.0, 1.17): ellipsoid("Body", (-0.045, y, 0.292), (0.032, 0.032, 0.022), GOLD, segments=8, rings=6) for x in (-0.235, 0.235): ellipsoid("Body", (x, 0.83, 0.243), (0.108, 0.10, 0.022), coat, True, tilt=x * 28, segments=12, rings=8) tube("Body", [(x - 0.07, 0.895, 0.266), (x + 0.07, 0.895, 0.266)], 0.012, GOLD, sides=5) # Leather sling and tiny clasp. The inventory props remain state-controlled. tube("Body", [(-0.26, 1.39, 0.18), (-0.06, 1.16, 0.302), (0.21, 0.88, 0.29), (0.34, 0.73, 0.07)], 0.027, LEATHER) ellipsoid("Body", (0.37, 0.74, 0.04), (0.15, 0.18, 0.12), LEATHER) ellipsoid("Body", (0.39, 0.80, 0.15), (0.04, 0.035, 0.015), GOLD, segments=8, rings=6) for side, x in (("Left", -0.39), ("Right", 0.39)): ellipsoid("Arm" + side, (x, 1.10, 0), (0.145, 0.31, 0.145), coat, True, tilt=-x * 10) ellipsoid("Arm" + side, (x, 0.82, 0.018), (0.145, 0.065, 0.148), scarf) ellipsoid("Arm" + side, (x, 0.73, 0.024), (0.123, 0.115, 0.12), fur) ellipsoid("Arm" + side, (x * 0.78, 0.745, 0.075), (0.055, 0.066, 0.05), fur, segments=10, rings=6) for side, x in (("Left", -0.18), ("Right", 0.18)): ellipsoid("Leg" + side, (x, 0.41, 0), (0.137, 0.28, 0.135), "52605a") ellipsoid("Leg" + side, (x, 0.14, 0.075), (0.16, 0.13, 0.225), LEATHER) tube("Leg" + side, [(x - 0.09, 0.22, 0.19), (x + 0.09, 0.22, 0.19)], 0.014, GOLD, sides=5) # A broad face, cheek planes and projecting muzzle, not a capsule head. head_w = 0.37 * stout ellipsoid("Head", (0, 1.76, 0), (head_w, 0.34, 0.29), fur) if species == "fox": for s in (-1, 1): tube("Head", [(s * 0.23, 1.99, 0), (s * 0.29, 2.19, -0.03), (s * 0.33, 2.39, -0.055)], [0.135, 0.095, 0.005], fur, sides=8) tube("Head", [(s * 0.24, 2.05, 0.105), (s * 0.30, 2.25, 0.02)], [0.06, 0.006], PINK, sides=6) ellipsoid("Head", (s * 0.19, 1.63, 0.21), (0.19, 0.15, 0.16), CREAM, tilt=s * 18) ellipsoid("Head", (0, 1.69, 0.29), (0.16, 0.105, 0.22), CREAM) tube("Tail", [(0, 0.81, -0.2), (0.17, 0.52, -0.49), (0.44, 0.54, -0.76), (0.56, 0.82, -0.76)], [0.12, 0.23, 0.24, 0.17], fur, sides=12) tube("Tail", [(0.53, 0.74, -0.78), (0.57, 0.95, -0.72), (0.48, 1.12, -0.60)], [0.195, 0.15, 0.003], CREAM, sides=12) elif species == "rabbit": leaf_ear("Head", -0.19, 1.98, 0.70, 0.10, fur, -12) leaf_ear("Head", 0.21, 1.96, 0.62, 0.12, fur, 22) ellipsoid("Tail", (0, 0.74, -0.36), (0.19, 0.18, 0.19), CREAM) elif species == "badger": for s in (-1, 1): ellipsoid("Head", (s * 0.30, 2.01, -0.02), (0.125, 0.145, 0.09), INK) ellipsoid("Head", (s * 0.30, 2.025, 0.053), (0.07, 0.085, 0.025), CREAM) ellipsoid("Head", (s * 0.19, 1.82, 0.226), (0.10, 0.25, 0.093), INK, tilt=s * -18) ellipsoid("Head", (0, 1.68, 0.32), (0.22, 0.14, 0.16), CREAM) ellipsoid("Tail", (0, 0.67, -0.34), (0.13, 0.25, 0.16), fur) else: for s in (-1, 1): ellipsoid("Head", (s * 0.31, 1.94, 0), (0.10, 0.115, 0.075), fur) ellipsoid("Head", (s * 0.32, 1.94, 0.063), (0.054, 0.064, 0.025), PINK) tube("Tail", [(0, 0.80, -0.22), (0, 0.46, -0.52), (0.18, 0.22, -0.78), (0.37, 0.19, -0.81)], [0.16, 0.15, 0.09, 0.008], fur, sides=10) if species != "fox": for s in (-1, 1): ellipsoid("Head", (s * 0.09, 1.64, 0.282), (0.15, 0.10, 0.115), CREAM) nose_z = 0.48 if species == "fox" else 0.408 ellipsoid("Head", (0, 1.706, nose_z), (0.058, 0.043, 0.047), PINK if species == "rabbit" else INK, segments=12, rings=8) for s in (-1, 1): eye_x = s * (0.16 if species != "badger" else 0.20) eye_z = 0.33 if species == "badger" else 0.26 ellipsoid("Head", (eye_x, 1.845, eye_z), (0.049, 0.061, 0.037), INK) ellipsoid("Head", (eye_x - 0.012, 1.868, eye_z + 0.03), (0.013, 0.018, 0.012), CREAM, segments=8, rings=6) tube("Head", [(eye_x - 0.047, 1.954, eye_z), (eye_x, 1.97 + s * 0.012, eye_z + 0.01), (eye_x + 0.042, 1.95, eye_z)], 0.017, CREAM if species == "badger" else INK, sides=6) ellipsoid("Head", (s * 0.26, 1.688, 0.248), (0.046, 0.024, 0.025), PINK, segments=10, rings=6) tube("Head", [(0, 1.659, nose_z - 0.008), (s * 0.06, 1.611, nose_z - 0.045), (s * 0.117, 1.636, nose_z - 0.085)], 0.009, INK, sides=5) # Accessories give each silhouette a small joke and a practical occupation. if species == "badger": for s in (-1, 1): points = [(s * 0.20 + math.cos(a * math.tau / 20) * 0.09, 1.845 + math.sin(a * math.tau / 20) * 0.08, 0.373) for a in range(21)] tube("Head", points, 0.009, GOLD, sides=5) tube("Head", [(-0.11, 1.85, 0.373), (0, 1.88, 0.39), (0.11, 1.85, 0.373)], 0.011, GOLD, sides=5) if species in ("otter", "rabbit"): hat_color = "d3b66f" if species == "rabbit" else "777d52" ellipsoid("Hair", (0, 2.025, -0.01), (0.44, 0.055, 0.34), hat_color) ellipsoid("Hair", (0, 2.09, -0.035), (0.28, 0.14, 0.245), hat_color) tube("Hair", [(-0.25, 2.063, 0.09), (0, 2.06, 0.23), (0.24, 2.063, 0.09)], 0.026, scarf) ellipsoid("Hair", (0.25, 2.15, 0.15), (0.038, 0.12, 0.065), "8f9c5a", tilt=40, segments=10, rings=6) else: # A little forelock above the brow, merged into the existing animated slot. ellipsoid("Hair", (-0.04, 2.04, 0.05), (0.11, 0.07, 0.15), fur, tilt=-25, segments=12, rings=8) finish(species) def grass(): reset() rng = random.Random(7103) verts, faces, uv = [], [], [] for blade in range(7): angle = blade * 2.399 center = Vector((math.cos(angle) * 0.15, math.sin(angle) * 0.15, 0)) width = rng.uniform(0.025, 0.055) height = rng.uniform(0.8, 1.0) sideways = Vector((math.cos(angle), math.sin(angle), 0)) bend = Vector((math.cos(angle + 0.8), math.sin(angle + 0.8), 0)) * rng.uniform(0.045, 0.12) start = len(verts) for level in (0, 0.42, 0.77): for side in (-1, 1): verts.append(center + Vector((0, 0, height * level)) + bend * level * level + sideways * side * width * (1 - level)) uv.append(((side + 1) / 2, level)) verts.append(center + Vector((0, 0, height)) + bend) uv.append((0.5, 1)) for level in range(2): a = start + level * 2 faces.extend([(a, a + 1, a + 2), (a + 1, a + 3, a + 2)]) faces.append((start + 4, start + 5, start + 6)) mesh = bpy.data.meshes.new("MeadowTuft") mesh.from_pydata(verts, [], faces) mesh.update() layer = mesh.uv_layers.new(name="UVMap") for loop in mesh.loops: layer.data[loop.index].uv = uv[loop.vertex_index] obj = bpy.data.objects.new("Grass", mesh) bpy.context.collection.objects.link(obj) paint(obj, "ffffff", "Grass") finish("meadow_grass") character("fox", "c17b42", "568b82", "d6934d") character("rabbit", "d4c5a5", "839d70", "c68061") character("badger", "b8b9a8", "7c879c", "c5aa6d") character("otter", "947055", "b77c58", "8caa98") grass() (OUTPUT / "mesh_budget.json").write_text(json.dumps(STATS, indent=2) + "\n") print("STORYBOOK ASSET BUDGET", json.dumps(STATS))