Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New Eye Tracking panel for Avatars 3.0 #599

Open
wants to merge 1 commit into
base: development
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions resources/translations.csv
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,14 @@ TestLowerlid.label,Test,テスト,테스트
TestLowerlid.desc,This lets you see how lowerlids will look in-game.,,게임 중에 아래 눈꺼풀이 어떻게 보이는 지 확인할 수 있습니다.
ResetBlinkTest.label,Reset Shapes,図形をリセットする,쉐이프 초기화
ResetBlinkTest.desc,This resets the blink testing.,,눈깜빡임 테스트를 초기화한다.
Av3EyeTrackingPanel.info1,Eye tracking is set up in Unity,,
Av3EyeTrackingPanel.info2,Rotate eyes up to simplify setup,,
Av3EyeTrackingRotateEyeBones.label,Rotate eye bones,,
Av3EyeTrackingRotateEyeBones.desc,Rotate eye bones to point straight up with zero roll. This simplifies setting up VRChat Avatars 3.0 eye tracking in Unity,,
Av3EyeTrackingRotateEyeBones.poll.noBones,No bones,,
Av3EyeTrackingRotateEyeBones.poll.notInCurrentEditMode,"{armature} is not in the current Edit mode, either exit the current Edit mode or open {armature} in Edit mode",,
Av3EyeTrackingRotateEyeBones.info.noChanges,"Eye bones are already rotated for VRChat, no changes were made.",,
Av3EyeTrackingRotateEyeBones.success,Eye bones rotated.,,
ImportAnyModel.label,Import Any Model,任意のモデルをインポート,아무 모델 불러오기
ImportAnyModel.desc2.79,"Import a model of any supported type.

Expand Down
131 changes: 131 additions & 0 deletions tools/eyetracking.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
import copy
import math
import bmesh
import mathutils

from collections import OrderedDict
from random import random
from itertools import chain

from . import common as Common
from . import armature as Armature
Expand All @@ -17,6 +19,135 @@
iris_heights = None


@register_wrap
class RotateEyeBonesForAv3Button(bpy.types.Operator):
"""Reorient eye bones to point straight up and have zero roll. This isn't necessary for VRChat because eye-tracking
rotations can be set separately per eye in Unity, however it does simplify setting up the eye-tracking rotations,
because both eyes can use the same rotations, and it makes it so that (0,0,0) rotation results in the eyes looking
forward."""
bl_idname = "cats_eyes.av3_orient_eye_bones"
bl_label = t("Av3EyeTrackingRotateEyeBones.label")
bl_description = t("Av3EyeTrackingRotateEyeBones.desc")
bl_options = {'REGISTER', 'UNDO', 'INTERNAL'}

@classmethod
def poll(cls, context: bpy.types.Context):
# The eye_left and eye_right properties already check that there is an armature, so we don't need to check that
# here.
scene = context.scene
if not (Common.is_enum_non_empty(scene.eye_left) or Common.is_enum_non_empty(scene.eye_right)):
cls.poll_message_set(t("Av3EyeTrackingRotateEyeBones.poll.noBones"))
return False

# If another Object is currently in EDIT mode and the armature is not also in the same EDIT mode, we cannot swap
# the armature into EDIT mode and then swap back to the original Object in its original EDIT mode because
# Undo/Redo will not work for the changes made by this Operator.
armature = Common.get_armature()
if context.object.mode == 'EDIT' and armature not in context.objects_in_mode:
cls.poll_message_set(t("Av3EyeTrackingRotateEyeBones.poll.notInCurrentEditMode", armature=armature.name))
return False

return True

def execute(self, context: bpy.types.Context) -> set[str]:
scene = context.scene

armature_obj = Common.get_armature()
armature = armature_obj.data

already_editing = armature_obj.mode == 'EDIT'

# If we're in EDIT mode already, we need to access the matrices of the edit bones because the bones may not be
# up-to-date.
if already_editing:
bones = armature.edit_bones
matrix_attribute = "matrix"
else:
bones = armature.bones
matrix_attribute = "matrix_local"

# Both bones could be set the same, so use a set to ensure we only have unique names
eye_bone_names = {scene.eye_left, scene.eye_right}

# The position of the head and tail are easy to compare from OBJECT mode through head_local and tail_local, but
# bone roll is not easily accessible.
# We can determine bone roll (and the overall orientation of the bone) through matrix_local.
# The expected matrix_local for a bone pointing straight up and with zero roll is a 90 degrees rotation about
# the X-axis and no other rotation.
straight_up_and_zero_roll = mathutils.Matrix.Rotation(math.pi/2, 3, 'X')

# Check each bone
for eye_bone_name in list(eye_bone_names):
bone = bones[eye_bone_name]

# Due to floating-point precision, it's unlikely that the bone's matrix_local will exactly match, so
# we'll check if it's close enough.
matrix_close_enough = True

# Create iterators to iterate through each value of the matrices in order
matrix_iter = chain.from_iterable(getattr(bone, matrix_attribute).to_3x3())
expected_matrix_iter = chain.from_iterable(straight_up_and_zero_roll)
for bone_val, expected_val in zip(matrix_iter, expected_matrix_iter):
# Note that while the values may be accessed as standard python float which is up to double-precision,
# mathutils.Matrix/Vector only store single-precision float, so the tolerances need to be more lenient
# than they might usually be.
if not math.isclose(bone_val, expected_val, rel_tol=1e-6, abs_tol=1e-6):
matrix_close_enough = False
break
if matrix_close_enough:
eye_bone_names.remove(eye_bone_name)

if not eye_bone_names:
# Both bones are already oriented correctly
self.report({'INFO'}, t("Av3EyeTrackingRotateEyeBones.info.noChanges"))
return {'CANCELLED'}

if not already_editing:
# Store active/selected/hidden object states, so they can be restored afterwards.
saved_data = Common.SavedData()

# set_default_stage will set the armature as active
armature_obj2 = Common.set_default_stage()
assert armature_obj == armature_obj2

# Bones can only be moved while in EDIT mode.
Common.switch('EDIT')

edit_bones = armature.edit_bones

# Get each eye's EditBone
eye_bones = {edit_bones[eye_bone_name] for eye_bone_name in eye_bone_names}

# Setting a bone's matrix doesn't currently update mirrored bones like when setting a bone's head/tail, but
# we'll temporarily disable mirroring in-case this changes in the future.
orig_mirroring = armature.use_mirror_x
armature.use_mirror_x = False

# We're going to result in moving the tails of the eye bones, but we don't want this to affect any other bones,
# so disconnect any bones that are connected to the eye bones.
for bone in edit_bones:
if bone.use_connect and bone.parent in eye_bones:
bone.use_connect = False

for eye_bone in eye_bones:
# Re-orient the bone to point straight up with zero roll, maintaining the original length and the position
# of the bone's head.
new_matrix = straight_up_and_zero_roll.to_4x4()
new_matrix.translation = eye_bone.matrix.translation
eye_bone.matrix = new_matrix

# Restore the mirror setting.
armature.use_mirror_x = orig_mirroring

if not already_editing:
Common.switch('OBJECT')
# Restore active/selected/hidden object states
saved_data.load()

self.report({'INFO'}, t("Av3EyeTrackingRotateEyeBones.success"))
return {'FINISHED'}


@register_wrap
class CreateEyesButton(bpy.types.Operator):
bl_idname = 'cats_eyes.create_eye_tracking'
Expand Down
10 changes: 10 additions & 0 deletions tools/register.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,17 @@
__make_annotations = (not bpy.app.version < (2, 79, 9))


def _dummy_operator_poll_message_set(message, *args):
"""Operator.poll_message_set was added in Blender 3.0. We add this function to Operator subclasses when it's not
present so that code that wants to use poll_message_set won't cause errors on older Blender versions"""
pass


def register_wrap(cls):
if issubclass(cls, bpy.types.Operator) and not hasattr(cls, "poll_message_set"):
# poll_message_set was added in Blender 3.0. To be able to use it on 3.0+, without causing errors on older
# Blender versions, we need to add a dummy function under the same attribute name to the class.
cls.poll_message_set = _dummy_operator_poll_message_set
if hasattr(cls, 'bl_rna'):
__bl_classes.append(cls)
cls = make_annotations(cls)
Expand Down
41 changes: 41 additions & 0 deletions ui/eye_tracking.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,47 @@ def invoke(self, context, event):
wm.invoke_search_popup(self)
return {'FINISHED'}


@register_wrap
class Av3EyeTrackingPanel(ToolPanel, bpy.types.Panel):
"""Avatars 3.0 version of the Eye Tracking Panel

Contains an operator to reorient eye bones so that they're pointing directly up and have zero roll."""
bl_idname = 'VIEW3D_PT_av3_eyetracking'
bl_label = t('EyeTrackingPanel.label')
bl_options = {'DEFAULT_CLOSED'}

@classmethod
def poll(cls, context):
return not context.scene.show_avatar_2_tabs

def draw(self, context):
scene = context.scene
layout = self.layout
box = layout.box()
col = box.column(align=True)

sub = col.column(align=True)
sub.scale_y = 0.75
sub.label(text=t("Av3EyeTrackingPanel.info1"), icon='INFO')
sub.label(text=t("Av3EyeTrackingPanel.info2"), icon='BLANK1')

row = col.row(align=True)
row.scale_y = 1.1
row.label(text=t('Scene.eye_left.label') + ":")
row.operator(SearchMenuOperatorBoneEyeLeft.bl_idname,
text=layout.enum_item_name(scene, "eye_left", scene.eye_left), icon='BONE_DATA')
row = col.row(align=True)
row.scale_y = 1.1
row.label(text=t('Scene.eye_right.label') + ":")
row.operator(SearchMenuOperatorBoneEyeRight.bl_idname,
text=layout.enum_item_name(scene, "eye_right", scene.eye_right), icon='BONE_DATA')

col = box.column(align=True)
row = col.row(align=True)
row.operator(Eyetracking.RotateEyeBonesForAv3Button.bl_idname, icon='CON_ROTLIMIT')


@register_wrap
class EyeTrackingPanel(ToolPanel, bpy.types.Panel):
bl_idname = 'VIEW3D_PT_eye_v3'
Expand Down