5bd7e56fae
Pipeline: Webcam → Z-Ray Facetracker → Unity (OpenSeeFace) → Avatar → OBS Components: - avatar.blend: Blender 5.1 source (31K verts, 52 ARKit blendshapes) - avatar.fbx: FBX export for Unity - NeonAvatarDriver.cs: Unity script mapping OpenSeeFace → blendshapes - create_avatar.py: Procedural avatar generation script Avatar: Female, 22y, black hair, blue eyes, slim, winter clothing Features: Idle breathing, auto-blink, head rotation tracking, mouth/brow tracking
349 lines
12 KiB
Python
349 lines
12 KiB
Python
import bpy
|
|
import bmesh
|
|
import math
|
|
import os
|
|
|
|
# ============================================================
|
|
# NEON AVATAR — Realistic Female Avatar for Face Tracking
|
|
# ============================================================
|
|
|
|
# Clear scene
|
|
bpy.ops.object.select_all(action='SELECT')
|
|
bpy.ops.object.delete()
|
|
for mesh in bpy.data.meshes:
|
|
bpy.data.meshes.remove(mesh)
|
|
|
|
# --- HELPERS ---
|
|
|
|
def add_subsurf(obj, levels=2):
|
|
mod = obj.modifiers.new(name="Subsurf", type='SUBSURF')
|
|
mod.levels = levels
|
|
mod.render_levels = levels
|
|
|
|
def shade_smooth(obj):
|
|
for poly in obj.data.polygons:
|
|
poly.use_smooth = True
|
|
|
|
def create_material(name, color, roughness=0.5, metallic=0.0):
|
|
mat = bpy.data.materials.new(name=name)
|
|
mat.use_nodes = True # compat
|
|
bsdf = mat.node_tree.nodes.get("Principled BSDF")
|
|
if bsdf:
|
|
bsdf.inputs['Base Color'].default_value = (*color, 1.0)
|
|
bsdf.inputs['Roughness'].default_value = roughness
|
|
bsdf.inputs['Metallic'].default_value = metallic
|
|
return mat
|
|
|
|
# --- HEAD ---
|
|
print("=== Creating head ===")
|
|
bpy.ops.mesh.primitive_uv_sphere_add(segments=64, ring_count=32, radius=0.5, location=(0, 0, 0))
|
|
head = bpy.context.active_object
|
|
head.name = "Head"
|
|
|
|
# Shape head
|
|
bpy.ops.object.mode_set(mode='EDIT')
|
|
bpy.ops.mesh.select_all(action='SELECT')
|
|
bpy.ops.transform.resize(value=(0.78, 1.0, 0.85))
|
|
bpy.ops.mesh.subdivide(number_cuts=3)
|
|
|
|
# Get bmesh in edit mode
|
|
bm = bmesh.from_edit_mesh(head.data)
|
|
for v in bm.verts:
|
|
# Narrower jaw
|
|
if v.co.z < -0.15:
|
|
v.co.x *= 0.85
|
|
v.co.y *= 0.9
|
|
# Rounder forehead
|
|
if v.co.z > 0.2:
|
|
v.co.x *= 0.95
|
|
v.co.y *= 1.05
|
|
# Pointed chin
|
|
if v.co.z < -0.35:
|
|
v.co.x *= 0.6
|
|
v.co.y *= 0.8
|
|
v.co.z *= 1.1
|
|
|
|
bmesh.update_edit_mesh(head.data)
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
|
|
add_subsurf(head, 2)
|
|
shade_smooth(head)
|
|
|
|
# --- EYES ---
|
|
print("=== Creating eyes ===")
|
|
for side, x in [("Left", -0.15), ("Right", 0.15)]:
|
|
bpy.ops.mesh.primitive_uv_sphere_add(segments=32, ring_count=16, radius=0.06, location=(x, -0.35, 0.05))
|
|
eye = bpy.context.active_object
|
|
eye.name = f"Eye_{side}"
|
|
shade_smooth(eye)
|
|
|
|
bpy.ops.mesh.primitive_uv_sphere_add(segments=16, ring_count=8, radius=0.025, location=(x, -0.40, 0.05))
|
|
iris = bpy.context.active_object
|
|
iris.name = f"Iris_{side}"
|
|
iris_mat = create_material(f"Iris_{side}", (0.2, 0.4, 0.8), 0.1, 0.3)
|
|
iris.data.materials.append(iris_mat)
|
|
|
|
bpy.ops.mesh.primitive_uv_sphere_add(segments=8, ring_count=4, radius=0.012, location=(x, -0.41, 0.05))
|
|
pupil = bpy.context.active_object
|
|
pupil.name = f"Pupil_{side}"
|
|
pupil_mat = create_material(f"Pupil_{side}", (0.05, 0.05, 0.05), 0.0)
|
|
pupil.data.materials.append(pupil_mat)
|
|
|
|
# Skin
|
|
skin_mat = create_material("Skin", (0.85, 0.7, 0.6), 0.6)
|
|
head.data.materials.append(skin_mat)
|
|
|
|
# --- BODY ---
|
|
print("=== Creating body ===")
|
|
bpy.ops.mesh.primitive_cube_add(size=1, location=(0, 0, -0.7))
|
|
torso = bpy.context.active_object
|
|
torso.name = "Torso"
|
|
torso.scale = (0.22, 0.13, 0.35)
|
|
bpy.ops.object.transform_apply(scale=True)
|
|
|
|
bpy.ops.object.mode_set(mode='EDIT')
|
|
bm = bmesh.from_edit_mesh(torso.data)
|
|
for v in bm.verts:
|
|
if abs(v.co.z) < 0.05:
|
|
v.co.x *= 0.85
|
|
if v.co.z < -0.15:
|
|
v.co.x *= 1.1
|
|
bmesh.update_edit_mesh(torso.data)
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
|
|
add_subsurf(torso, 2)
|
|
shade_smooth(torso)
|
|
torso.data.materials.append(skin_mat)
|
|
|
|
for side, x in [("Left", -0.28), ("Right", 0.28)]:
|
|
bpy.ops.mesh.primitive_cylinder_add(vertices=16, radius=0.05, depth=0.45, location=(x, 0, -0.75))
|
|
arm = bpy.context.active_object
|
|
arm.name = f"Arm_{side}"
|
|
arm.rotation_euler = (math.radians(15), 0, math.radians(5 if side == "Left" else -5))
|
|
shade_smooth(arm)
|
|
arm.data.materials.append(skin_mat)
|
|
|
|
for side, x in [("Left", -0.1), ("Right", 0.1)]:
|
|
bpy.ops.mesh.primitive_cylinder_add(vertices=16, radius=0.07, depth=0.7, location=(x, 0, -1.2))
|
|
leg = bpy.context.active_object
|
|
leg.name = f"Leg_{side}"
|
|
shade_smooth(leg)
|
|
leg.data.materials.append(skin_mat)
|
|
|
|
# --- HAIR ---
|
|
print("=== Creating hair ===")
|
|
bpy.ops.mesh.primitive_uv_sphere_add(segments=48, ring_count=24, radius=0.52, location=(0, 0.02, 0))
|
|
hair = bpy.context.active_object
|
|
hair.name = "Hair"
|
|
|
|
bpy.ops.object.mode_set(mode='EDIT')
|
|
bm = bmesh.from_edit_mesh(hair.data)
|
|
for v in bm.verts:
|
|
if v.co.z < -0.1:
|
|
v.co.z = -0.1
|
|
if v.co.y > 0.3:
|
|
v.co.y *= 1.3
|
|
v.co.z -= 0.05
|
|
bmesh.update_edit_mesh(hair.data)
|
|
bpy.ops.object.mode_set(mode='OBJECT')
|
|
|
|
add_subsurf(hair, 2)
|
|
shade_smooth(hair)
|
|
hair_mat = create_material("Hair", (0.05, 0.05, 0.05), 0.3, 0.1)
|
|
hair.data.materials.append(hair_mat)
|
|
|
|
# --- CLOTHING ---
|
|
print("=== Creating clothing ===")
|
|
bpy.ops.mesh.primitive_cube_add(size=1, location=(0, 0.02, -0.7))
|
|
jacket = bpy.context.active_object
|
|
jacket.name = "Jacket"
|
|
jacket.scale = (0.26, 0.16, 0.38)
|
|
bpy.ops.object.transform_apply(scale=True)
|
|
add_subsurf(jacket, 1)
|
|
shade_smooth(jacket)
|
|
jacket_mat = create_material("Jacket", (0.15, 0.15, 0.2), 0.8)
|
|
jacket.data.materials.append(jacket_mat)
|
|
|
|
# --- ARKIT BLENDSHAPES ---
|
|
print("=== Creating ARKit blendshapes ===")
|
|
arkit_shapes = [
|
|
"EyeBlinkLeft", "EyeLookInLeft", "EyeLookOutLeft", "EyeLookUpLeft", "EyeLookDownLeft",
|
|
"EyeBlinkRight", "EyeLookInRight", "EyeLookOutRight", "EyeLookUpRight", "EyeLookDownRight",
|
|
"EyeSquintLeft", "EyeSquintRight", "EyeWideLeft", "EyeWideRight",
|
|
"JawOpen", "JawLeft", "JawRight", "JawForward",
|
|
"MouthClose", "MouthFunnel", "MouthPucker", "MouthLeft", "MouthRight",
|
|
"MouthSmileLeft", "MouthSmileRight", "MouthFrownLeft", "MouthFrownRight",
|
|
"MouthDimpleLeft", "MouthDimpleRight", "MouthStretchLeft", "MouthStretchRight",
|
|
"MouthRollLower", "MouthRollUpper", "MouthShrugLower", "MouthShrugUpper",
|
|
"MouthPressLeft", "MouthPressRight", "MouthLowerOuterLeft", "MouthLowerOuterRight",
|
|
"MouthUpperOuterLeft", "MouthUpperOuterRight",
|
|
"CheekSquintLeft", "CheekSquintRight", "CheekPuff",
|
|
"NoseSneerLeft", "NoseSneerRight",
|
|
"BrowInnerUp", "BrowInnerDown",
|
|
"BrowOuterUpLeft", "BrowOuterUpRight", "BrowOuterDownLeft", "BrowOuterDownRight",
|
|
]
|
|
|
|
head.shape_key_add(name="Basis", from_mix=False)
|
|
|
|
for shape_name in arkit_shapes:
|
|
sk = head.shape_key_add(name=shape_name, from_mix=False)
|
|
sk.value = 0.0
|
|
|
|
verts = head.data.vertices
|
|
n = len(verts)
|
|
|
|
if "Blink" in shape_name:
|
|
for i, v in enumerate(verts):
|
|
if "Left" in shape_name and v.co.x < 0 and v.co.y < -0.3 and 0.0 < v.co.z < 0.15:
|
|
sk.data[i].co.y += 0.02
|
|
elif "Right" in shape_name and v.co.x > 0 and v.co.y < -0.3 and 0.0 < v.co.z < 0.15:
|
|
sk.data[i].co.y += 0.02
|
|
|
|
elif "Smile" in shape_name:
|
|
for i, v in enumerate(verts):
|
|
if "Left" in shape_name and v.co.x < -0.05 and v.co.y < -0.35 and v.co.z < -0.1:
|
|
sk.data[i].co.y -= 0.015
|
|
sk.data[i].co.x -= 0.01
|
|
elif "Right" in shape_name and v.co.x > 0.05 and v.co.y < -0.35 and v.co.z < -0.1:
|
|
sk.data[i].co.y -= 0.015
|
|
sk.data[i].co.x += 0.01
|
|
|
|
elif "JawOpen" in shape_name:
|
|
for i, v in enumerate(verts):
|
|
if v.co.z < -0.25 and v.co.y < -0.2:
|
|
sk.data[i].co.z -= 0.05
|
|
|
|
elif "Brow" in shape_name and "Up" in shape_name:
|
|
for i, v in enumerate(verts):
|
|
if v.co.y < -0.35 and v.co.z > 0.1:
|
|
if "Left" in shape_name and v.co.x < 0:
|
|
sk.data[i].co.z += 0.015
|
|
elif "Right" in shape_name and v.co.x > 0:
|
|
sk.data[i].co.z += 0.015
|
|
elif "Inner" in shape_name:
|
|
sk.data[i].co.z += 0.012
|
|
|
|
print(f"Created {len(arkit_shapes)} blendshapes")
|
|
|
|
# --- IDLE ANIMATIONS ---
|
|
print("=== Creating idle animations ===")
|
|
scene = bpy.context.scene
|
|
scene.frame_start = 1
|
|
scene.frame_end = 120
|
|
|
|
# Breathing
|
|
torso.animation_data_create()
|
|
action = bpy.data.actions.new(name="Idle_Breathe")
|
|
torso.animation_data.action = action
|
|
for frame in range(1, 121):
|
|
breath = math.sin(frame * 2 * math.pi / 96) * 0.015
|
|
torso.scale[2] = 0.35 + breath
|
|
torso.keyframe_insert(data_path="scale", index=2, frame=frame)
|
|
|
|
# Blinking
|
|
head.animation_data_create()
|
|
blink_action = bpy.data.actions.new(name="Idle_Anim")
|
|
head.animation_data.action = blink_action
|
|
|
|
blink_left = head.data.shape_keys.key_blocks.get("EyeBlinkLeft")
|
|
blink_right = head.data.shape_keys.key_blocks.get("EyeBlinkRight")
|
|
|
|
if blink_left and blink_right:
|
|
for blink_frame in [30, 75]:
|
|
for offset in range(-3, 5):
|
|
f = blink_frame + offset
|
|
if f < 1 or f > 120:
|
|
continue
|
|
if offset == 0:
|
|
blink_left.value = 1.0
|
|
blink_right.value = 1.0
|
|
elif abs(offset) == 1:
|
|
blink_left.value = 0.5
|
|
blink_right.value = 0.5
|
|
elif abs(offset) == 2:
|
|
blink_left.value = 0.1
|
|
blink_right.value = 0.1
|
|
else:
|
|
blink_left.value = 0.0
|
|
blink_right.value = 0.0
|
|
blink_left.keyframe_insert(data_path="value", frame=f)
|
|
blink_right.keyframe_insert(data_path="value", frame=f)
|
|
|
|
# Weight shift
|
|
for frame in range(1, 121):
|
|
head.location.x = math.sin(frame * 2 * math.pi / 120) * 0.01
|
|
head.location.y = math.sin(frame * 2 * math.pi / 80) * 0.005
|
|
head.keyframe_insert(data_path="location", frame=frame)
|
|
|
|
# Hair sway
|
|
hair.animation_data_create()
|
|
hair_action = bpy.data.actions.new(name="Idle_Hair")
|
|
hair.animation_data.action = hair_action
|
|
for frame in range(1, 121):
|
|
hair.rotation_euler[1] = math.sin(frame * 2 * math.pi / 60) * 0.005
|
|
hair.keyframe_insert(data_path="rotation_euler", index=1, frame=frame)
|
|
|
|
# --- CAMERA ---
|
|
print("=== Setting up camera ===")
|
|
bpy.ops.object.camera_add(location=(0, -1.2, 0.1))
|
|
cam = bpy.context.active_object
|
|
cam.name = "AvatarCamera"
|
|
cam.rotation_euler = (math.radians(88), 0, 0)
|
|
scene.camera = cam
|
|
|
|
# --- LIGHTING ---
|
|
print("=== Setting up lighting ===")
|
|
bpy.ops.object.light_add(type='AREA', location=(0.3, -0.8, 0.5))
|
|
key_light = bpy.context.active_object
|
|
key_light.data.energy = 200
|
|
key_light.data.size = 0.5
|
|
key_light.rotation_euler = (math.radians(60), math.radians(20), 0)
|
|
|
|
bpy.ops.object.light_add(type='AREA', location=(-0.3, -0.6, 0.2))
|
|
fill_light = bpy.context.active_object
|
|
fill_light.data.energy = 80
|
|
fill_light.data.size = 0.8
|
|
|
|
bpy.ops.object.light_add(type='AREA', location=(0, 0.5, 0.6))
|
|
back_light = bpy.context.active_object
|
|
back_light.data.energy = 100
|
|
back_light.data.size = 0.5
|
|
|
|
# --- RENDER ---
|
|
scene.render.engine = 'CYCLES'
|
|
scene.cycles.samples = 32
|
|
scene.cycles.use_denoising = True
|
|
scene.render.resolution_x = 1280
|
|
scene.render.resolution_y = 720
|
|
scene.render.fps = 30
|
|
|
|
world = bpy.data.worlds["World"]
|
|
world.use_nodes = True
|
|
bg = world.node_tree.nodes.get("Background")
|
|
if bg:
|
|
bg.inputs[0].default_value = (0.05, 0.05, 0.08, 1.0)
|
|
bg.inputs[1].default_value = 0.5
|
|
|
|
# --- SAVE ---
|
|
output = "/home/natiris/Dokumente/neon-avatar/avatar.blend"
|
|
bpy.ops.wm.save_as_mainfile(filepath=output)
|
|
print(f"=== Saved: {output} ===")
|
|
|
|
# Export FBX
|
|
fbx = "/home/natiris/Dokumente/neon-avatar/avatar.fbx"
|
|
bpy.ops.export_scene.fbx(
|
|
filepath=fbx,
|
|
use_mesh_modifiers=True,
|
|
add_leaf_bones=False,
|
|
bake_anim=True,
|
|
bake_anim_step=1,
|
|
)
|
|
print(f"=== Exported: {fbx} ===")
|
|
|
|
# Summary
|
|
print(f"\n=== SUMMARY ===")
|
|
print(f"Head: {len(head.data.vertices)} verts, {len(head.data.shape_keys.key_blocks)} shape keys")
|
|
print(f"Blendshapes: {len(arkit_shapes)} ARKit")
|
|
print(f"Animations: Breathing, Blinking, Weight Shift, Hair Sway")
|
|
print(f"Materials: Skin, Hair(black), Eyes(blue), Jacket")
|