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
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
OpenSeeFace/
|
||||
*.blend1
|
||||
@@ -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<string, int> blendshapeMap = new Dictionary<string, int>();
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
Binary file not shown.
BIN
Binary file not shown.
@@ -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")
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 650 KiB |
Reference in New Issue
Block a user