281 lines
14 KiB
Python
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))
|