"""Quellgeflecht: living Wurzelbund healer with weighted vine body and spring core. Blender Z up, -Y forward. Run in a fresh background Blender process. """ from pathlib import Path import bpy, bmesh, math, random, json, hashlib from mathutils import Vector, Quaternion from math import sin, cos, pi P = Path(__file__).resolve().parents[2] OUT = P / 'assets/characters' OUT.mkdir(exist_ok=True) SRC = Path(__file__).resolve().parent random.seed(71) bpy.ops.wm.read_factory_settings(use_empty=True) S = 1.0 parts = [] def coord(v): return Vector(v) * S def material(name, color, rough=.8, metal=0.0, emission=None): m = bpy.data.materials.new('QG_' + name) m.diffuse_color = (*color, 1) m.use_nodes = True n = m.node_tree.nodes l = m.node_tree.links b = n.get('Principled BSDF') b.inputs['Base Color'].default_value = (*color, 1) b.inputs['Roughness'].default_value = rough b.inputs['Metallic'].default_value = metal if emission is not None: b.inputs['Emission Color'].default_value = (*emission, 1) b.inputs['Emission Strength'].default_value = 1.5 else: v = n.new('ShaderNodeVertexColor') v.layer_name = 'SurfaceColor' l.new(v.outputs['Color'], b.inputs['Base Color']) noise = n.new('ShaderNodeTexNoise') noise.inputs['Scale'].default_value = 22 noise.inputs['Detail'].default_value = 3.0 bump = n.new('ShaderNodeBump') bump.inputs['Strength'].default_value = .22 bump.inputs['Distance'].default_value = .015 l.new(noise.outputs['Fac'], bump.inputs['Height']) l.new(bump.outputs['Normal'], b.inputs['Normal']) return m M = { 'bark': material('Bark', (.22, .16, .10), .88), 'vine': material('Vine', (.28, .42, .18), .78), 'moss': material('Moss', (.34, .52, .16), .82), 'leaf': material('Leaf', (.42, .62, .22), .7), 'petal': material('Petal', (.72, .48, .55), .65), 'spring': material('Spring', (.45, .85, .78), .35, emission=(.35, .9, .75)), 'wood': material('Wood', (.40, .28, .14), .8), } def mesh(name, verts, faces, kind, bone, uv=None, colors=None, weights=None, smooth=True): me = bpy.data.meshes.new(name) me.from_pydata([coord(v) for v in verts], [], faces) me.update() ob = bpy.data.objects.new(name, me) bpy.context.collection.objects.link(ob) me.materials.append(M[kind]) parts.append(ob) for f in me.polygons: f.use_smooth = smooth tex = me.uv_layers.new(name='UVMap') col = me.color_attributes.new(name='SurfaceColor', type='FLOAT_COLOR', domain='CORNER') base = M[kind].diffuse_color[:3] for f in me.polygons: for li in f.loop_indices: i = me.loops[li].vertex_index v = verts[i] tex.data[li].uv = uv[i] if uv else (v[0] * .9 + v[1] * .1, v[2] * 1.1) fac = .90 + .10 * sin(v[0] * 21 + v[2] * 9) + .04 * cos(v[1] * 37) c = colors[i] if colors else tuple(max(.01, x * fac) for x in base) col.data[li].color = (*c, 1) groups = {} for i in range(len(verts)): ws = weights[i] if weights else {bone: 1.0} total = sum(ws.values()) for key, w in ws.items(): if key not in groups: groups[key] = ob.vertex_groups.new(name=key) groups[key].add([i], w / total, 'REPLACE') return ob def mixweights(t, bones): if len(bones) == 1: return {bones[0]: 1} t = max(0, min(.999999, t)) * (len(bones) - 1) i = int(t) w = t - i return {bones[i]: 1 - w, bones[i + 1]: w} def tube(name, points, radii, kind, bones, sides=16, steps=28, flatten=1.0, groove=.05): points = [Vector(x) for x in points] vs, uv, ws, loops, fs = [], [], [], [], [] def point(t): a = min(len(points) - 2, int(t * (len(points) - 1))) u = t * (len(points) - 1) - a p0, p1, p2, p3 = points[max(a - 1, 0)], points[a], points[a + 1], points[min(a + 2, len(points) - 1)] p = .5 * ((2 * p1) + (-p0 + p2) * u + (2 * p0 - 5 * p1 + 4 * p2 - p3) * u * u + (-p0 + 3 * p1 - 3 * p2 + p3) * u * u * u) return p, radii[a] * (1 - u) + radii[a + 1] * u for j in range(steps + 1): t = j / steps p, r = point(t) tan = (point(min(1, t + .002))[0] - point(max(0, t - .002))[0]).normalized() u = tan.cross(Vector((0, 1, 0))).normalized() v = tan.cross(u) loop = [] for k in range(sides): a = 2 * pi * k / sides mod = 1 + groove * (sin(a * 5 + t * 6) + .3 * sin(a * 9 - t * 5)) vs.append(p + r * mod * (u * cos(a) + v * sin(a) * flatten)) uv.append((k / sides, t)) ws.append(mixweights(t, bones)) loop.append(len(vs) - 1) loops.append(loop) for a, b in zip(loops, loops[1:]): for k in range(sides): q = (k + 1) % sides fs.append((a[k], a[q], b[q], b[k])) fs.extend([tuple(reversed(loops[0])), tuple(loops[-1])]) return mesh(name, vs, fs, kind, bones[0], uv, weights=ws) def ell(name, c, r, kind, bone, segments=18, rings=10): vs = [(c[0], c[1], c[2] + r[2])] fs = [] for j in range(1, rings): th = pi * j / rings for k in range(segments): a = 2 * pi * k / segments f = 1 + .06 * sin(a * 3 + j) vs.append((c[0] + r[0] * sin(th) * cos(a) * f, c[1] + r[1] * sin(th) * sin(a) * f, c[2] + r[2] * cos(th))) vs.append((c[0], c[1], c[2] - r[2])) end = len(vs) - 1 for k in range(segments): fs.append((0, 1 + k, 1 + (k + 1) % segments)) for j in range(rings - 2): for k in range(segments): a = 1 + j * segments + k b = 1 + j * segments + (k + 1) % segments fs.append((a, a + segments, b + segments, b)) for k in range(segments): fs.append((end, end - 1 - k, end - 1 - (k + 1) % segments)) return mesh(name, vs, fs, kind, bone) def leaf(name, start, end, width, bone, kind='leaf'): a = Vector(start) b = Vector(end) d = b - a u = d.cross(Vector((0, 1, 0))).normalized() if abs(d.y) < .9 else d.cross(Vector((1, 0, 0))).normalized() v = d.normalized().cross(u) vs, fs = [], [] for side in [-1, 1]: for j in range(7): t = j / 6 w = width * sin(pi * t) ** .65 for k in [-1, 0, 1]: vs.append(a + d * t + u * w * k + v * (sin(pi * t) * .03) + Vector((0, side * .004, 0))) for layer in range(2): for j in range(6): for k in range(2): i = layer * 21 + j * 3 + k quad = (i, i + 1, i + 4, i + 3) fs.append(quad if layer else tuple(reversed(quad))) for j in range(6): i = j * 3 fs.extend([(i, i + 3, i + 24, i + 21), (i + 2, i + 23, i + 26, i + 5)]) fs.extend([(0, 21, 22, 23, 2, 1), (18, 19, 20, 41, 40, 39)]) return mesh(name, vs, fs, kind, bone) # ---- Body: braided living spring ---- # Root braids for k in range(5): a = 2 * pi * k / 5 tube('RootBraid_%d' % k, [(.16 * cos(a), .12 * sin(a), .02), (.22 * cos(a + .3), .18 * sin(a + .3), .18), (.14 * cos(a + .5), .10 * sin(a + .5), .38), (.08 * cos(a), .05 * sin(a), .52)], [.07, .08, .06, .05], 'bark', ['Root', 'Pelvis'], 10, 16) ell('Base_Wood', (0, 0, .08), (.22, .18, .08), 'wood', 'Root', 16, 8) # Trunk weave tube('Trunk', [(0, .02, .40), (0, .0, .75), (.02, -.02, 1.10), (0, 0, 1.38)], [.18, .22, .20, .15], 'vine', ['Pelvis', 'Spine', 'Chest'], 18, 32, flatten=.85) for k in range(8): a = k * 2 * pi / 8 tube('TrunkWeave_%d' % k, [(.12 * cos(a), .08 + .10 * sin(a), .48), (.18 * cos(a + .2), .02 + .16 * sin(a + .2), .85), (.16 * cos(a - .1), .0 + .14 * sin(a - .1), 1.15), (.08 * cos(a), .04 + .08 * sin(a), 1.36)], [.04, .05, .04, .02], 'vine' if k % 2 else 'moss', ['Pelvis', 'Spine', 'Chest'], 8, 20) # Spring orb in chest ell('SpringCore', (0, -.12, 1.05), (.14, .10, .14), 'spring', 'Spine', 16, 10) ell('SpringHalo', (0, -.10, 1.05), (.20, .12, .20), 'spring', 'Spine', 14, 8) # Crown / head: woven nest with drooping leaves and a glowing bud ell('Head_Nest', (0, 0, 1.55), (.22, .20, .18), 'bark', 'Head', 18, 10) ell('Head_Bud', (0, -.08, 1.68), (.10, .08, .12), 'spring', 'Head', 14, 8) for k in range(7): a = 2 * pi * k / 7 leaf('CrownLeaf_%d' % k, (.12 * cos(a), .08 * sin(a), 1.62), (.28 * cos(a), .18 * sin(a), 1.82 + .04 * (k % 2)), .06, 'Head', 'leaf' if k % 2 else 'moss') for k in range(4): a = 2 * pi * k / 4 + .3 leaf('Petal_%d' % k, (.04 * cos(a), -.06 + .04 * sin(a), 1.72), (.12 * cos(a), -.14 + .05 * sin(a), 1.84), .035, 'Head', 'petal') # Arms: long healing vines with leaf fans and droplet tips for s, side in [(-1, 'L'), (1, 'R')]: arm = ['UpperArm.' + side, 'Forearm.' + side, 'Hand.' + side] tube('Arm_' + side, [(s * .22, 0, 1.28), (s * .42, -.08, 1.10), (s * .55, -.18, .88), (s * .58, -.28, .72)], [.08, .07, .055, .04], 'vine', arm, 12, 24, flatten=.9) # Leaf fan at wrist for j in range(4): a = j * 1.2 leaf('HandLeaf_%s%d' % (side, j), (s * .58, -.28, .72), (s * (.72 + .04 * j), -.36 - .02 * j, .62 + .06 * sin(a)), .04, arm[2], 'leaf') ell('Drop_' + side, (s * .58, -.32, .66), (.035, .03, .045), 'spring', arm[2], 10, 6) # Shoulder leaves for j in range(3): leaf('ShoulderLeaf_%s%d' % (side, j), (s * .20, .02, 1.34), (s * (.34 + .05 * j), .10, 1.48 + .04 * j), .05, 'Chest', 'moss') # Back canopy of vines and moss for k in range(10): a = k * .55 z = .9 + (k % 4) * .16 tube('BackVine_%d' % k, [(.05 * cos(a), .12, z), (.18 * cos(a), .22, z + .18), (.24 * cos(a), .18, z + .32)], [.03, .035, .01], 'vine', ['Chest' if z > 1.05 else 'Spine'], 8, 14) leaf('BackLeaf_%d' % k, (.18 * cos(a), .22, z + .18), (.30 * cos(a), .12, z + .30), .05, 'Chest' if z > 1.05 else 'Spine', 'leaf') for j in range(24): a = j * 1.7 z = .5 + (j % 8) * .12 ell('MossClump_%02d' % j, (.12 * cos(a) * (1 + .05 * j), .08 * sin(a), z), (.035, .028, .02), 'moss', 'Spine' if z < 1.0 else 'Chest', 8, 5) # Cleanup and skin for ob in parts: bm = bmesh.new() bm.from_mesh(ob.data) bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=.0000005) bmesh.ops.triangulate(bm, faces=list(bm.faces)) bmesh.ops.dissolve_degenerate(bm, edges=list(bm.edges), dist=.0000001) bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) if bm.calc_volume(signed=True) < 0: bmesh.ops.reverse_faces(bm, faces=list(bm.faces)) bm.to_mesh(ob.data) bm.free() ob.data.update() bpy.ops.object.armature_add(enter_editmode=True) rig = bpy.context.object rig.name = 'QuellRig' ed = rig.data.edit_bones ed.remove(ed[0]) defs = {} def bone(name, head, tail, parent=None): b = ed.new(name) b.head = coord(head) b.tail = coord(tail) if parent: b.parent = ed[parent] defs[name] = (head, tail, parent) bone('Root', (0, 0, 0), (0, 0, .12)) bone('Pelvis', (0, 0, .40), (0, 0, .75), 'Root') bone('Spine', (0, 0, .75), (0, 0, 1.10), 'Pelvis') bone('Chest', (0, 0, 1.10), (0, 0, 1.40), 'Spine') bone('Head', (0, 0, 1.40), (0, 0, 1.75), 'Chest') for s, side in [(-1, 'L'), (1, 'R')]: bone('UpperArm.' + side, (s * .22, 0, 1.28), (s * .42, -.08, 1.10), 'Chest') bone('Forearm.' + side, (s * .42, -.08, 1.10), (s * .55, -.18, .88), 'UpperArm.' + side) bone('Hand.' + side, (s * .55, -.18, .88), (s * .58, -.28, .68), 'Forearm.' + side) bone('Thigh.' + side, (s * .10, .04, .40), (s * .12, .02, .22), 'Pelvis') bone('Shin.' + side, (s * .12, .02, .22), (s * .12, 0, .06), 'Thigh.' + side) bone('Foot.' + side, (s * .12, 0, .06), (s * .12, -.12, .03), 'Shin.' + side) bpy.ops.object.mode_set(mode='OBJECT') for ob in parts: mod = ob.modifiers.new('Weighted vine', 'ARMATURE') mod.object = rig ob.parent = rig clips = {'idle': (3.2, True), 'start': (.4, False), 'walk': (1.4, True), 'stop': (.4, False), 'attack_a': (1.3, False), 'attack_b': (1.3, False), 'hit': (.48, False), 'ability': (1.9, False), 'death': (2.0, False)} def curve(t, keys): for i in range(len(keys) - 1): a, x = keys[i] b, y = keys[i + 1] if t <= b: u = max(0, (t - a) / (b - a)) u = u * u * (3 - 2 * u) return x * (1 - u) + y * u return keys[-1][1] def animate(name, t, dur): rotations = {} locs = {} def rot(b, x=0, y=0, z=0): rotations[b] = (x, y, z) if name == 'idle': q = sin(2 * pi * t / dur) q2 = sin(2 * pi * t / dur * 2) rot('Spine', .015 * q) rot('Chest', -.02 * q, 0, .01 * q) rot('Head', .025 * q, 0, -.012 * q) for s, side in [(-1, 'L'), (1, 'R')]: rot('Forearm.' + side, .03 * q, 0, s * .02 * q2) locs['Root'] = (0, 0, .004 * q) elif name in ('walk', 'start', 'stop'): fade = 1 if name == 'walk' else sin(pi * .5 * t / dur) if name == 'start' else cos(pi * .5 * t / dur) phase = t / 1.4 * 2 * pi roll = sin(phase) * fade rot('Pelvis', .01, 0, .04 * roll) rot('Spine', -.03 * fade, 0, -.05 * roll) rot('Chest', .02 * fade, 0, .03 * roll) rot('Head', .04 * fade, 0, -.02 * roll) for s, side in [(-1, 'L'), (1, 'R')]: q = sin(phase + (0 if s < 0 else pi)) lift = max(0, q) rot('Thigh.' + side, .28 * q * fade) rot('Shin.' + side, -.32 * lift * fade) rot('Foot.' + side, (-.28 * q + .32 * lift) * fade) rot('UpperArm.' + side, -.12 * q * fade, 0, s * .03 * fade) rot('Forearm.' + side, -.15 * fade - .05 * lift, 0, s * .04 * fade) locs['Root'] = (0, 0, (-.008 + .008 * cos(phase * 2)) * fade) elif name == 'attack_a': # Left tendril pulse, contact at 0.70 q = curve(t / dur, [(0, 0), (.32, -.30), (.54, -.50), (.70, 1), (.82, .80), (1, 0)]) rot('Spine', -.08 * q, .06 * q, .05 * q) rot('Chest', -.06 * q, .05 * q, .08 * q) rot('Head', .08 * q, 0, -.05 * q) rot('UpperArm.L', -.85 * q, -.10 * q, .15 * q) rot('Forearm.L', -.40 * q, 0, .10 * q) rot('Hand.L', .25 * q) rot('UpperArm.R', .12 * q) elif name == 'attack_b': q = curve(t / dur, [(0, 0), (.32, -.30), (.54, -.50), (.70, 1), (.82, .80), (1, 0)]) rot('Spine', -.08 * q, -.06 * q, -.05 * q) rot('Chest', -.06 * q, -.05 * q, -.08 * q) rot('Head', .08 * q, 0, .05 * q) rot('UpperArm.R', -.85 * q, .10 * q, -.15 * q) rot('Forearm.R', -.40 * q, 0, -.10 * q) rot('Hand.R', .25 * q) rot('UpperArm.L', .12 * q) elif name == 'hit': q = curve(t, [(0, 0), (.08, 1), (.22, .5), (.48, 0)]) rot('Spine', .12 * q, 0, -.07 * q) rot('Head', .14 * q, 0, .05 * q) rot('Forearm.L', -.12 * q) rot('Forearm.R', -.10 * q) elif name == 'ability': # Multi-heal: arms open, core flares, leaves lift q = curve(t / dur, [(0, 0), (.35, .7), (.70, 1), (1.3, .85), (1.9, 0)]) rot('Spine', .10 * q) rot('Chest', .08 * q) rot('Head', -.18 * q) for s, side in [(-1, 'L'), (1, 'R')]: rot('UpperArm.' + side, -.35 * q, 0, s * .55 * q) rot('Forearm.' + side, -.50 * q, 0, s * .25 * q) rot('Hand.' + side, .20 * q) locs['Root'] = (0, 0, .04 * q) elif name == 'death': q = curve(t, [(0, 0), (.2, .08), (.7, .45), (1.25, 1), (1.55, .90), (2.0, 1)]) rot('Root', -.55 * q, 0, .08 * q) locs['Root'] = (0, -.08 * q, -.08 * q) rot('Spine', -.20 * q) rot('Chest', -.15 * q) rot('Head', -.25 * q) for s, side in [(-1, 'L'), (1, 'R')]: rot('UpperArm.' + side, -.40 * q, 0, s * .25 * q) rot('Forearm.' + side, -.55 * q) for pb in rig.pose.bones: pb.rotation_mode = 'QUATERNION' r = rotations.get(pb.name, (0, 0, 0)) q = Quaternion((0, 0, 1), r[2]) @ Quaternion((0, 1, 0), r[1]) @ Quaternion((1, 0, 0), r[0]) basis = pb.bone.matrix_local.to_quaternion() pb.rotation_quaternion = basis.inverted() @ q @ basis pb.location = pb.bone.matrix_local.to_3x3().inverted() @ (Vector(locs.get(pb.name, (0, 0, 0))) * S) for name, (dur, loop) in clips.items(): rig.animation_data_clear() action = bpy.data.actions.new(name) action.use_fake_user = True rig.animation_data_create() rig.animation_data.action = action for frame in range(round(dur * 30) + 1): animate(name, frame / 30, dur) for pb in rig.pose.bones: pb.keyframe_insert('rotation_quaternion', frame=frame) pb.keyframe_insert('location', frame=frame) action['loop'] = loop rig.animation_data_clear() rig.animation_data_create() rig.animation_data.action = bpy.data.actions['idle'] for pb in rig.pose.bones: pb.rotation_quaternion = Quaternion() pb.location = (0, 0, 0) bpy.context.scene.render.fps = 30 bpy.context.scene.frame_set(0) bpy.ops.wm.save_as_mainfile(filepath=str(SRC / 'quellgeflecht.blend')) bpy.ops.file.make_paths_relative() bpy.ops.wm.save_as_mainfile(filepath=str(SRC / 'quellgeflecht.blend')) bpy.ops.object.select_all(action='DESELECT') for ob in parts: ob.select_set(True) bpy.context.view_layer.objects.active = parts[0] bpy.ops.object.join() body = bpy.context.object body.name = 'QuellBody' body.select_set(True) rig.select_set(True) bpy.context.view_layer.objects.active = rig bpy.ops.export_scene.gltf( filepath=str(OUT / 'quellgeflecht.glb'), export_format='GLB', use_selection=True, export_animations=True, export_animation_mode='ACTIONS', export_force_sampling=True, export_frame_range=False, export_anim_slide_to_zero=True, export_skins=True, export_all_influences=False, export_def_bones=True, export_anim_single_armature=True, export_materials='EXPORT', export_yup=True, ) raw = (OUT / 'quellgeflecht.glb').read_bytes() import struct doc = json.loads(raw[20:20 + struct.unpack_from('