UNPKG

@needle-tools/facefilter

Version:

Needle Engine FaceFilter

425 lines (349 loc) 16.7 kB
import { Animator, AssetReference, Behaviour, isDevEnvironment, Mathf, NEEDLE_progressive, serializable } from '@needle-tools/engine'; import type { NeedleFilterTrackingManager } from './FaceFilter.js'; import { BufferAttribute, Matrix4, Mesh, Object3D, SkinnedMesh, Vector3, Vector3Like } from 'three'; import { BlendshapeName, FacefilterUtils } from './utils.js'; declare type AvatarType = "Unknown" | "ReadyPlayerMe"; /** * Root Filter behaviour */ export class FaceFilterRoot extends Behaviour { /** * Create a new FaceFilterRoot from a given URL. This can then be added to the FaceFilterManager * @param url The URL of the asset to load * @param opts Options for the filter * @returns A promise that resolves to the FaceFilterRoot instance * @example * ```typescript * const filter = await FaceFilterRoot.create("path/to/asset.glb", { scale: 0.9, offset: { x: 0, y: 0.1, z: 0 } }); * if(filter) manager.activateFilter(filter); * ``` */ static async create(url: string, opts?: { scale?: number, offset?: Vector3Like }): Promise<FaceFilterRoot | null> { const instance = await AssetReference.getOrCreateFromUrl(url).instantiate(); if (opts) { const headPosition = new Object3D(); instance?.add(headPosition); headPosition.addComponent(FaceFilterHeadPosition); // Note: everything is inverted because scaling the head position small makes the head appear big (this is so we can adjust the head gizmo in an editor for example) // Similarly: moving the head gizmo forwards makes the head model appear to move backwards if (opts.scale) { const invertedScale = 1 / opts.scale; headPosition.scale.set(invertedScale, invertedScale, invertedScale); } if (opts.offset) { headPosition.position.set(-1 * opts.offset.x, -1 * opts.offset.y, -1 * opts.offset.z); } } return instance?.getOrAddComponent(FaceFilterRoot) || null; } /** * When enabled the default occluder will not be rendered */ @serializable() overrideDefaultOccluder: boolean = false; private _type: AvatarType = "Unknown"; private _headMatrix: Matrix4 | null = null; private _initialScale!: Vector3; private _leftEye: Object3D | null = null; private _rightEye: Object3D | null = null; awake() { this._initialScale ??= this.gameObject?.scale.clone(); this._headMatrix = null; this.setupHead(); this.loadProgressive(); } private setupHead() { let head: Object3D | null = null; let headTopEnd: Object3D | null = null; // First check if we have a face placement helper assigned // If so this is the exact object that we use for the face position const face = this.gameObject.getComponentInChildren(FaceFilterHeadPosition); if (face) { head = face.gameObject; } else { const readyPlayerMeBodyAssetNames = ["Wolf3D_Body", "Wolf3D_Outfit_Bottom", "Wolf3D_Outfit_Footwear", "Wolf3D_Outfit_Top"]; const bodyAssetsToHide = new Array<Object3D>(); /** Fallback method to determine the head matrix */ const scanAvatar = (obj: Object3D): void => { if (!head) { if (obj.userData?.name === "Head") { head = obj; } } if (obj.userData?.name === "HeadTop_End") { headTopEnd = obj; } if (readyPlayerMeBodyAssetNames.includes(obj.userData?.name)) { bodyAssetsToHide.push(obj); } // Is this a ReadyPlayerMe avatar? if (obj.userData?.name === "Wolf3D_Head") { this._type = "ReadyPlayerMe"; } // Get eyes if (obj.userData?.name === "LeftEye") { this._leftEye = obj; console.log(this); } else if (obj.userData?.name === "RightEye") { this._rightEye = obj; } // Traverse for (const child of obj.children) { scanAvatar(child); } } scanAvatar(this.gameObject); if (head) { const headOffsetObject = new Object3D(); (head as Object3D).add(headOffsetObject); head = headOffsetObject; // handle specific defaults for different avatar types switch (this._type) { case "ReadyPlayerMe": this.overrideDefaultOccluder = true; headOffsetObject.position.set(0, .07, .05); bodyAssetsToHide.forEach(obj => obj.visible = false); break; } } else { if (isDevEnvironment()) console.debug("No head object found in filter (" + this.name + ")"); else console.debug("No head object found in filter (" + this.name + ")"); } } if (head) { // The matrix of the root object should not affect the head object // E.g. if the root avatar is offset in the scene at the moment/scaled/rotated... it doesnt matter // We only care about the matrix of the found head WITHIN this rig const parent = this.gameObject.parent; this.gameObject.parent = null; this.gameObject.matrixAutoUpdate = false; this.gameObject.matrix.identity(); this.gameObject.matrixWorld.identity(); // Calculate the head matrix head.updateWorldMatrix(true, false); this._headMatrix = new Matrix4(); this._headMatrix.copy(head.matrixWorld); // apply the scale of the initial object // const scale = this._initialScale.clone(); // scale.x = 1 / scale.x; // scale.y = 1 / scale.y; // scale.z = 1 / scale.z; // this._headMatrix.scale(scale); // the matrix will be used to transform the root to the head so we invert it this._headMatrix.invert(); // when we are rendering in mirror mode we want to flip the head matrix FacefilterUtils.flipX(this._headMatrix); // Reset the parent this.gameObject.parent = parent; } else { // apply the root scale if nothing is setup and no head object is found this._headMatrix = new Matrix4(); this._headMatrix.scale(this._initialScale); } if (this._leftEye || this._rightEye) { const eyes = this.gameObject.getOrAddComponent(FaceFilterEyeBehaviour); eyes.eyeLeft = this._leftEye; eyes.eyeRight = this._rightEye; this._behaviours?.push(eyes); } } private loadProgressive() { this.gameObject.traverse(t => { if (t instanceof Mesh) { const vertices = t.geometry.getAttribute("position") as BufferAttribute; if (!vertices?.array || vertices.array.length < 100_000) { NEEDLE_progressive.assignMeshLOD(t, 0); if (Array.isArray(t.material)) t.material.forEach(m => NEEDLE_progressive.assignTextureLOD(m, 0)); else NEEDLE_progressive.assignTextureLOD(t.material, 0); } else { console.debug(`Will not automatically load progressive mesh for ${t.name} because it has too many vertices (${vertices.array?.length})`); } } }) } private _filter: NeedleFilterTrackingManager | null = null; private _behaviours: FilterBehaviour[] = []; private _index: number = -1; onResultsUpdated(filter: NeedleFilterTrackingManager, index: number) { this._index = index; if (!this._filter) { this._filter = filter; console.debug("Avatar behaviour initialized"); this.gameObject.getOrAddComponent(FaceFilterBlendshapes); this.gameObject.getOrAddComponent(FaceFilterAnimator); this._behaviours = this.gameObject.getComponentsInChildren(FilterBehaviour); } for (const beh of this._behaviours) { beh.onResultsUpdated(filter, index); } } onBeforeRender(): void { const res = this._filter?.facelandmarkerResult; if (!res || this._index == -1) return; const lm = res.facialTransformationMatrixes[this._index]; if (!lm) return; FacefilterUtils.applyFaceLandmarkMatrixToObject3D(this.gameObject, lm, this.context.mainCamera); this.gameObject.matrixAutoUpdate = false; if (this._headMatrix) { this.gameObject.matrix.multiply(this._headMatrix); } } } export interface IFilterBehaviour { onResultsUpdated(filter: NeedleFilterTrackingManager, index: number): void; } export abstract class FilterBehaviour extends Behaviour implements IFilterBehaviour { abstract onResultsUpdated(_filter: NeedleFilterTrackingManager, index: number): void; } /** * Marks the face position in the avatar */ export class FaceFilterHeadPosition extends Behaviour { @serializable(Matrix4) matrix: Matrix4 = new Matrix4(); } declare type MeshWithBlendshapes = Mesh & { morphTargetInfluences: number[]; morphTargetDictionary: { [key: string]: number }; } declare type RemapData = { key: string, factor: number }; declare type BlendshapeMap = Partial<Record<BlendshapeName, RemapData>>; export class FaceFilterBlendshapes extends FilterBehaviour { // TODO: expose in Unity @serializable() blendshapeMap: BlendshapeMap = {}; private _skinnedMeshes: MeshWithBlendshapes[] = []; onEnable(): void { this._skinnedMeshes = []; this.blendshapeMap ??= {}; this.gameObject.traverse((child) => { if (child instanceof SkinnedMesh || child instanceof Mesh) { const mesh = child as MeshWithBlendshapes; if (mesh.morphTargetDictionary && mesh.morphTargetInfluences) { this._skinnedMeshes.push(mesh); for (const key of Object.keys(mesh.morphTargetDictionary)) { switch (key) { case "mouthOpen": this.blendshapeMap["jawOpen"] = { key, factor: 3, }; break; case "mouthSmile": this.blendshapeMap["mouthSmileLeft"] = { key, factor: 1, }; this.blendshapeMap["mouthSmileRight"] = { key, factor: 1, }; break; } } } } }); if (Object.keys(this.blendshapeMap).length > 0) console.debug("Blendshape mapping", this.blendshapeMap); } onResultsUpdated(filter: NeedleFilterTrackingManager, _index: number) { const face = filter.facelandmarkerResult?.faceBlendshapes?.[_index] if (face && this._skinnedMeshes.length > 0) { const t = this.context.time.deltaTime / .03; // we iterate all blendshape values and set the corresponding morph target influence // some meshes might have different names so we need to remap them for (const shape of face.categories) { const blendshapeName = shape.categoryName; let name = shape.categoryName; const remapData: RemapData = this.blendshapeMap[name]; if (remapData?.key) { name = remapData.key; } for (const mesh of this._skinnedMeshes) { const index = mesh.morphTargetDictionary[name]; if (index !== undefined && index !== null) { let value = shape.score; if (remapData?.factor != undefined) { value *= remapData.factor; } // The eye blink values seem to never exceed ranges between 0 (totally open) and 0.5 (totally closed) else if (blendshapeName.includes("eyeBlink")) { value = value * 1.5; } if (filter.maxFaces === 1) { mesh.morphTargetInfluences[index] = value; } else { mesh.morphTargetInfluences[index] = Mathf.lerp(mesh.morphTargetInfluences[index], value, t); } } // else { // if (this.context.time.frameCount % 180 === 0) // console.warn(`No morph target found for blendshape ${name}`, mesh.morphTargetDictionary) // } } } } } } export class FaceFilterAnimator extends FilterBehaviour { private _animators: Animator[] = []; awake(): void { this._animators = this.gameObject.getComponentsInChildren(Animator); } onResultsUpdated(filter: NeedleFilterTrackingManager, index: number): void { if (!this._animators?.length) return; const face = filter.facelandmarkerResult?.faceBlendshapes?.[index]; if (face) { // we iterate all blendshape values and set the corresponding morph target influence // some meshes might have different names so we need to remap them for (const shape of face.categories) { const name = shape.categoryName; for (const anim of this._animators) { // if(name.includes("jawOpen")) console.log(shape.score) anim.setFloat(name, shape.score); } } } } } const leftEyeDownIndex = 11; const rightEyeDownIndex = 12; const leftEyeLookInIndex = 13; const rightEyeLookInIndex = 14; const leftEyeLookOutIndex = 15; const rightEyeLookOutIndex = 16; const leftEyeLookUpIndex = 17; const rightEyeLookUpIndex = 18; const leftEyeBlinkIndex = 9; const rightEyeBlinkIndex = 10; export class FaceFilterEyeBehaviour extends FilterBehaviour { @serializable(Object3D) eyeRight: Object3D | null = null; @serializable(Object3D) eyeLeft: Object3D | null = null; onResultsUpdated(_filter: NeedleFilterTrackingManager, index: number): void { const face = _filter.facelandmarkerResult?.faceBlendshapes?.[index]; if (!face) return; // TODO: we currently assume that Z is the forward axis if (this.eyeLeft) { // const leftBlink = face.categories[leftEyeBlinkIndex].score; const leftDown = face.categories[leftEyeDownIndex].score; const leftIn = face.categories[leftEyeLookInIndex].score; const leftOut = face.categories[leftEyeLookOutIndex].score; const leftUp = face.categories[leftEyeLookUpIndex].score; this.updateRotation(this.eyeLeft!, leftDown, leftUp, -leftIn, -leftOut); } if (this.eyeRight) { const rightDown = face.categories[rightEyeDownIndex].score; const rightIn = face.categories[rightEyeLookInIndex].score; const rightOut = face.categories[rightEyeLookOutIndex].score; const rightUp = face.categories[rightEyeLookUpIndex].score; this.updateRotation(this.eyeRight!, rightDown, rightUp, rightIn, rightOut); } } private updateRotation(object: Object3D, down: number, up: number, left: number, right: number) { down *= 1.5; up *= 1.5; const x = ((up - down) * -0.6) const y = ((left - right) * 0.6); object.rotation.set(x, y, object.rotation.z); } }