Files
gamedev-the-steward/tools/art/build_storybook_assets.py

281 lines
14 KiB
Python

"""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))