commit 5bd7e56fae01b29525ef3c0392439ecb30253885 Author: arch_agent Date: Wed Jul 15 11:01:17 2026 +0200 Neon Avatar v0.1 — Realistic VTuber avatar for face tracking 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a43c0dc --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +OpenSeeFace/ +*.blend1 diff --git a/NeonAvatarDriver.cs b/NeonAvatarDriver.cs new file mode 100644 index 0000000..dc555ed --- /dev/null +++ b/NeonAvatarDriver.cs @@ -0,0 +1,367 @@ +/* + * NeonAvatarDriver.cs — Face Tracking Driver for Unity + * ===================================================== + * Receives OpenSeeFace tracking data from Z-Ray Facetracker (UDP 11573) + * and maps it to a standard SkinnedMeshRenderer with ARKit blendshapes. + * + * No VRM needed — works with any avatar that has ARKit-compatible blendshapes. + * + * Setup: + * 1. Import avatar FBX with blendshapes into Unity + * 2. Add this script to a GameObject + * 3. Assign the SkinnedMeshRenderer of the avatar's head + * 4. Make sure Z-Ray Facetracker is running (flatpak run de.z_ray.Facetracker) + */ + +using UnityEngine; +using OpenSee; +using System.Collections.Generic; + +public class NeonAvatarDriver : MonoBehaviour +{ + [Header("References")] + [Tooltip("The OpenSeeFace receiver component")] + public OpenSee openSeeReceiver; + + [Tooltip("SkinnedMeshRenderer of the avatar head with blendshapes")] + public SkinnedMeshRenderer headMesh; + + [Tooltip("SkinnedMeshRenderer of the avatar body (optional, for body movement)")] + public SkinnedMeshRenderer bodyMesh; + + [Header("Head Movement")] + [Tooltip("How much the avatar head rotates with the webcam tracking")] + public float headRotationScale = 1.0f; + + [Tooltip("Smooth factor for head rotation (higher = smoother)")] + public float headSmoothing = 5.0f; + + [Tooltip("Transform of the head bone (for rotation)")] + public Transform headBone; + + [Header("Eye Tracking")] + public bool enableEyeTracking = true; + public float eyeRotationScale = 1.0f; + + [Header("Blendshape Settings")] + [Tooltip("Prefix for blendshape names (empty if using ARKit names directly)")] + public string blendshapePrefix = ""; + + [Tooltip("Multiplier for mouth blendshapes")] + public float mouthScale = 1.5f; + + [Tooltip("Multiplier for brow blendshapes")] + public float browScale = 1.2f; + + [Tooltip("Multiplier for eye blendshapes")] + public float eyeScale = 1.0f; + + [Header("Idle Animation")] + [Tooltip("Enable idle breathing animation")] + public bool enableBreathing = true; + + [Tooltip("Breathing speed")] + public float breathingSpeed = 0.3f; + + [Tooltip("Breathing intensity")] + public float breathingIntensity = 0.015f; + + [Header("Auto Blink")] + public bool enableAutoBlink = true; + public float minBlinkInterval = 3.0f; + public float maxBlinkInterval = 8.0f; + public float blinkSpeed = 0.15f; + + // Private state + private Dictionary blendshapeMap = new Dictionary(); + private Quaternion smoothedHeadRotation = Quaternion.identity; + private float breathingTimer = 0f; + private float nextBlinkTime = 0f; + private float blinkState = 0f; + private bool blinking = false; + + // ARKit blendshape names (standard 52) + private static readonly string[] 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", + }; + + void Start() + { + // Build blendshape name → index map + if (headMesh != null && headMesh.sharedMesh != null) + { + var mesh = headMesh.sharedMesh; + for (int i = 0; i < mesh.blendShapeCount; i++) + { + string name = mesh.GetBlendShapeName(i); + blendshapeMap[name] = i; + } + + Debug.Log($"[NeonAvatar] Found {blendshapeMap.Count} blendshapes on {headMesh.name}"); + + // Log which ARKit shapes are present + int found = 0; + foreach (var shape in ARKIT_SHAPES) + { + if (blendshapeMap.ContainsKey(blendshapePrefix + shape)) + found++; + } + Debug.Log($"[NeonAvatar] {found}/{ARKIT_SHAPES.Length} ARKit blendshapes found"); + } + + // Set next blink time + ScheduleNextBlink(); + } + + void Update() + { + // Breathing + if (enableBreathing && bodyMesh != null) + { + breathingTimer += Time.deltaTime * breathingSpeed; + float breath = Mathf.Sin(breathingTimer * 2f * Mathf.PI) * breathingIntensity; + // Scale chest slightly + bodyMesh.transform.localScale = new Vector3( + bodyMesh.transform.localScale.x, + bodyMesh.transform.localScale.y + breath * 0.01f, + bodyMesh.transform.localScale.z + ); + } + + // Auto blink + if (enableAutoBlink) + { + HandleAutoBlink(); + } + + // Process face tracking data + if (openSeeReceiver == null || openSeeReceiver.trackingData == null || openSeeReceiver.trackingData.Length == 0) + return; + + var data = openSeeReceiver.trackingData[0]; + if (data == null) + return; + + // --- Head Rotation --- + if (headBone != null) + { + // OpenSeeFace provides head rotation as quaternion + Quaternion targetRot = data.rotation; + targetRot = Quaternion.Euler( + -targetRot.x * headRotationScale, + targetRot.y * headRotationScale, + -targetRot.z * headRotationScale + ); + + smoothedHeadRotation = Quaternion.Slerp(smoothedHeadRotation, targetRot, Time.deltaTime * headSmoothing); + headBone.localRotation = smoothedHeadRotation; + } + + // --- Eye Open/Close --- + if (enableEyeTracking) + { + SetBlendshape("EyeBlinkLeft", (1f - data.leftEyeOpen) * eyeScale); + SetBlendshape("EyeBlinkRight", (1f - data.rightEyeOpen) * eyeScale); + } + + // --- Mouth --- + // OpenSeeFace provides mouth-related data via landmarks + // We need to compute blendshape values from the facial landmarks + if (data.landmarks != null && data.landmarks.Length >= 70) + { + ComputeMouthBlendshapes(data.landmarks); + ComputeBrowBlendshapes(data.landmarks); + } + + // --- Eye Gaze --- + if (enableEyeTracking && data.landmarks != null) + { + SetBlendshape("EyeLookInLeft", 0f); + SetBlendshape("EyeLookOutLeft", 0f); + SetBlendshape("EyeLookUpLeft", 0f); + SetBlendshape("EyeLookDownLeft", 0f); + SetBlendshape("EyeLookInRight", 0f); + SetBlendshape("EyeLookOutRight", 0f); + SetBlendshape("EyeLookUpRight", 0f); + SetBlendshape("EyeLookDownRight", 0f); + } + } + + void ComputeMouthBlendshapes(Vector2[] landmarks) + { + if (landmarks.Length < 70) + return; + + // OpenSeeFace 68-point landmark indices: + // 48-67 = mouth + // 0-16 = jaw + // 17-21 = left eyebrow + // 22-26 = right eyebrow + // 27-30 = nose + // 31-35 = nostrils + // 36-41 = left eye + // 42-47 = right eye + + // Mouth corners (48 = left, 54 = right) + Vector2 mouthLeft = landmarks[48]; + Vector2 mouthRight = landmarks[54]; + Vector2 mouthTop = landmarks[51]; + Vector2 mouthBottom = landmarks[57]; + Vector2 mouthCenter = (mouthLeft + mouthRight) * 0.5f; + + // Mouth width (smile detection) + float mouthWidth = Vector2.Distance(mouthLeft, mouthRight); + float mouthHeight = Vector2.Distance(mouthTop, mouthBottom); + + // Jaw/face width for normalization + float faceWidth = Vector2.Distance(landmarks[0], landmarks[16]); + if (faceWidth < 0.01f) faceWidth = 0.01f; + + // Normalize + float normalizedWidth = mouthWidth / faceWidth; + float normalizedHeight = mouthHeight / faceWidth; + + // Mouth open (JawOpen) + float jawOpen = Mathf.Clamp01((normalizedHeight - 0.02f) * 10f) * mouthScale; + SetBlendshape("JawOpen", jawOpen); + + // Smile detection — corners higher than center + float leftCornerHeight = mouthCenter.y - mouthLeft.y; + float rightCornerHeight = mouthCenter.y - mouthRight.y; + + float smileLeft = Mathf.Clamp01(leftCornerHeight * 5f) * mouthScale; + float smileRight = Mathf.Clamp01(rightCornerHeight * 5f) * mouthScale; + SetBlendshape("MouthSmileLeft", smileLeft); + SetBlendshape("MouthSmileRight", smileRight); + + // Frown — corners lower than center + float frownLeft = Mathf.Clamp01(-leftCornerHeight * 5f) * mouthScale; + float frownRight = Mathf.Clamp01(-rightCornerHeight * 5f) * mouthScale; + SetBlendshape("MouthFrownLeft", frownLeft); + SetBlendshape("MouthFrownRight", frownRight); + + // Mouth pucker + float pucker = Mathf.Clamp01(0.35f - normalizedWidth) * 3f * mouthScale; + SetBlendshape("MouthPucker", pucker); + + // Mouth close (when mouth is slightly open but lips together) + if (normalizedHeight < 0.03f) + { + SetBlendshape("MouthClose", 0.5f); + } + else + { + SetBlendshape("MouthClose", 0f); + } + } + + void ComputeBrowBlendshapes(Vector2[] landmarks) + { + // Left eyebrow: 17-21, Right eyebrow: 22-26 + // Eye center for reference + Vector2 leftEyeCenter = (landmarks[36] + landmarks[39]) * 0.5f; + Vector2 rightEyeCenter = (landmarks[42] + landmarks[45]) * 0.5f; + + // Inner brows (points 21 and 22) + Vector2 leftInnerBrow = landmarks[21]; + Vector2 rightInnerBrow = landmarks[22]; + + // Outer brows (points 17 and 26) + Vector2 leftOuterBrow = landmarks[17]; + Vector2 rightOuterBrow = landmarks[26]; + + // Brow heights relative to eyes + float leftInnerHeight = leftEyeCenter.y - leftInnerBrow.y; + float rightInnerHeight = rightEyeCenter.y - rightInnerBrow.y; + float leftOuterHeight = leftEyeCenter.y - leftOuterBrow.y; + float rightOuterHeight = rightEyeCenter.y - rightOuterBrow.y; + + // Normalize by face width + float faceWidth = Vector2.Distance(landmarks[0], landmarks[16]); + if (faceWidth < 0.01f) faceWidth = 0.01f; + + float normLeftInner = leftInnerHeight / faceWidth; + float normRightInner = rightInnerHeight / faceWidth; + float normLeftOuter = leftOuterHeight / faceWidth; + float normRightOuter = rightOuterHeight / faceWidth; + + // Brow up + SetBlendshape("BrowInnerUp", Mathf.Clamp01(normLeftInner * 8f) * browScale); + SetBlendshape("BrowOuterUpLeft", Mathf.Clamp01(normLeftOuter * 8f) * browScale); + SetBlendshape("BrowOuterUpRight", Mathf.Clamp01(normRightOuter * 8f) * browScale); + + // Brow down (when brow is lower than neutral) + SetBlendshape("BrowInnerDown", Mathf.Clamp01(-normLeftInner * 8f + 0.3f) * browScale); + SetBlendshape("BrowOuterDownLeft", Mathf.Clamp01(-normLeftOuter * 8f + 0.3f) * browScale); + SetBlendshape("BrowOuterDownRight", Mathf.Clamp01(-normRightOuter * 8f + 0.3f) * browScale); + } + + void HandleAutoBlink() + { + if (blinking) + { + blinkState += Time.deltaTime / blinkSpeed; + float blinkVal = 0f; + + if (blinkState < 0.5f) + blinkVal = blinkState * 2f; // Close + else if (blinkState < 1f) + blinkVal = (1f - blinkState) * 2f; // Open + else + { + blinkVal = 0f; + blinking = false; + blinkState = 0f; + ScheduleNextBlink(); + } + + // Only apply if tracking isn't already closing the eyes + if (openSeeReceiver == null || openSeeReceiver.trackingData == null || openSeeReceiver.trackingData.Length == 0) + { + SetBlendshape("EyeBlinkLeft", blinkVal); + SetBlendshape("EyeBlinkRight", blinkVal); + } + } + else if (Time.time > nextBlinkTime) + { + blinking = true; + blinkState = 0f; + } + } + + void ScheduleNextBlink() + { + nextBlinkTime = Time.time + Random.Range(minBlinkInterval, maxBlinkInterval); + } + + void SetBlendshape(string name, float value) + { + string fullName = blendshapePrefix + name; + if (blendshapeMap.TryGetValue(fullName, out int index)) + { + headMesh.SetBlendShapeWeight(index, Mathf.Clamp01(value) * 100f); + } + } + + void OnGUI() + { + // Debug info + if (openSeeReceiver != null && openSeeReceiver.trackingData != null && openSeeReceiver.trackingData.Length > 0) + { + GUI.Label(new Rect(10, 10, 300, 20), $"Tracking: {openSeeReceiver.receivedPackets} packets"); + } + } +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d232188 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# 🎮 Neon Avatar — Realistic VTuber Avatar + +Realistic female avatar (22y, black hair, blue eyes) for face tracking via webcam. + +## Pipeline + +``` +Webcam → Z-Ray Facetracker (Flatpak) → UDP:11573 → Unity (OpenSeeFace) → Avatar → OBS +``` + +## Components + +| File | Description | +|---|---| +| `avatar.blend` | Blender 5.1 source file (editable) | +| `avatar.fbx` | FBX export for Unity (22MB, 31K verts, 52 blendshapes) | +| `NeonAvatarDriver.cs` | Unity script — receives OpenSeeFace data, maps to blendshapes | +| `create_avatar.py` | Blender script that generated the avatar | + +## Setup + +### 1. Z-Ray Facetracker + +```bash +flatpak install flathub de.z_ray.Facetracker +flatpak run de.z_ray.Facetracker +``` + +### 2. Unity Project + +1. Install Unity Hub: `flatpak install flathub com.unity.UnityHub` +2. Create new 3D project (Unity 2022 LTS or newer) +3. Import `avatar.fbx` into Assets +4. Copy `NeonAvatarDriver.cs` and OpenSeeFace `Unity/` folder into Assets +5. Create GameObject with `OpenSee` component (listenPort: 11573) +6. Create GameObject with `NeonAvatarDriver` component +7. Assign OpenSee receiver + avatar SkinnedMeshRenderer +8. Press Play + +### 3. OBS Integration + +- Add Unity window capture or Spout plugin +- Or use virtual camera from Unity + +## Avatar Specs + +- **Gender:** Female, 22 years +- **Hair:** Medium, black +- **Eyes:** Blue +- **Body:** Slim +- **Clothing:** Winter-appropriate (dark jacket) +- **Blendshapes:** 52 ARKit-compatible +- **Idle Animations:** Breathing, Blinking, Weight Shift, Hair Sway +- **Lighting:** 3-point (Key, Fill, Back) +- **Camera:** Front-facing 1280x720 + +## Customization + +Open `avatar.blend` in Blender to: +- Adjust facial features +- Change hair style/length +- Modify body proportions +- Add clothing details +- Refine blendshape deformations +- Export new FBX with `File → Export → FBX` diff --git a/avatar.blend b/avatar.blend new file mode 100644 index 0000000..ee9a2fa Binary files /dev/null and b/avatar.blend differ diff --git a/avatar.fbx b/avatar.fbx new file mode 100644 index 0000000..6c8be72 Binary files /dev/null and b/avatar.fbx differ diff --git a/create_avatar.py b/create_avatar.py new file mode 100644 index 0000000..89c6b41 --- /dev/null +++ b/create_avatar.py @@ -0,0 +1,348 @@ +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") diff --git a/preview.png b/preview.png new file mode 100644 index 0000000..adb2174 Binary files /dev/null and b/preview.png differ