@convai/embody 1.0.1 → 1.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +12 -8
  3. package/dist/character/createCharacter.d.ts +66 -5
  4. package/dist/character/createCharacter.js +93 -22
  5. package/dist/character/pipeline.d.ts +9 -5
  6. package/dist/clips/animations.d.ts +13 -1
  7. package/dist/clips/animations.js +28 -6
  8. package/dist/clips/retarget.d.ts +168 -0
  9. package/dist/clips/retarget.js +856 -0
  10. package/dist/clips/sources.d.ts +70 -0
  11. package/dist/clips/sources.js +119 -0
  12. package/dist/connect/session.d.ts +11 -0
  13. package/dist/connect/session.js +22 -0
  14. package/dist/index.d.ts +22 -1
  15. package/dist/index.js +12 -0
  16. package/dist/inspect/inspectModel.d.ts +14 -0
  17. package/dist/inspect/inspectModel.js +26 -0
  18. package/dist/inspect/morphMap.d.ts +54 -0
  19. package/dist/inspect/morphMap.js +140 -0
  20. package/dist/inspect/report.d.ts +88 -0
  21. package/dist/inspect/report.js +193 -0
  22. package/dist/motion/eyeAim.d.ts +94 -0
  23. package/dist/motion/eyeAim.js +276 -0
  24. package/dist/motion/eyeGaze.d.ts +17 -0
  25. package/dist/motion/eyeGaze.js +42 -0
  26. package/dist/motion/footLock.d.ts +67 -0
  27. package/dist/motion/footLock.js +176 -0
  28. package/dist/motion/gaze.d.ts +135 -180
  29. package/dist/motion/gaze.js +257 -491
  30. package/dist/motion/hair.d.ts +27 -0
  31. package/dist/motion/hair.js +127 -0
  32. package/dist/motion/hairClearance.d.ts +39 -0
  33. package/dist/motion/hairClearance.js +125 -0
  34. package/dist/motion/morphNormals.d.ts +38 -0
  35. package/dist/motion/morphNormals.js +237 -0
  36. package/dist/motion/types.d.ts +15 -0
  37. package/dist/react/CharacterModel.d.ts +3 -1
  38. package/dist/react/CharacterModel.js +9 -1
  39. package/dist/react/ConvaiCharacter.js +4 -1
  40. package/dist/react/PortraitLights.d.ts +3 -0
  41. package/dist/react/PortraitLights.js +44 -0
  42. package/dist/render/faceMaterials.d.ts +60 -3
  43. package/dist/render/faceMaterials.js +320 -96
  44. package/dist/render/portraitRig.d.ts +28 -0
  45. package/dist/render/portraitRig.js +55 -0
  46. package/dist/render/surfaces.d.ts +181 -0
  47. package/dist/render/surfaces.js +1020 -0
  48. package/dist/rig/resolve.d.ts +8 -2
  49. package/dist/rig/resolve.js +7 -3
  50. package/dist/rig/units.d.ts +35 -16
  51. package/dist/rig/units.js +143 -42
  52. package/dist/speech/expression.d.ts +20 -0
  53. package/dist/speech/expression.js +118 -0
  54. package/dist/speech/lipsync.d.ts +68 -2
  55. package/dist/speech/lipsync.js +205 -5
  56. package/dist/speech/mouthFinish.d.ts +46 -0
  57. package/dist/speech/mouthFinish.js +235 -0
  58. package/dist/speech/voiceClock.d.ts +94 -0
  59. package/dist/speech/voiceClock.js +176 -0
  60. package/package.json +1 -1
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Hair that rests on the shoulders.
3
+ *
4
+ * Avatar hair is skinned to the head bone alone, with no strand bones. So a
5
+ * 45° head turn swings long hair rigidly into the shoulders and chest, and
6
+ * every head movement carries the whole hairstyle like a helmet.
7
+ *
8
+ * `restHairOnShoulders` re-weights each hair vertex by its bind height — the
9
+ * head above the head bone, the upper chest at shoulder height, blended
10
+ * between — so the crown and face frame follow the head and the lengths stay
11
+ * on the body, bending in between. Skin weights only, every renderer.
12
+ *
13
+ * It is checked against the body before it is kept, because a hairstyle the
14
+ * default does not suit (a bun, a braid lying on the back, hair on a hood)
15
+ * can be pushed into the skin or clothing: tried in the head turns and nods
16
+ * gaze makes (±45° yaw, ±20° pitch), a mesh it pushes further into the body
17
+ * than its own weights did keeps its own weights.
18
+ *
19
+ * (Secondary motion — a spring swinging the ends — was tried and removed:
20
+ * hair moving on its own read worse than hair that rests.)
21
+ */
22
+ import * as THREE from 'three';
23
+ /**
24
+ * Re-skin hair so its lengths rest on the shoulders. Idempotent per geometry.
25
+ * Returns the number of meshes re-weighted.
26
+ */
27
+ export declare function restHairOnShoulders(root: THREE.Object3D): number;
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Hair that rests on the shoulders.
3
+ *
4
+ * Avatar hair is skinned to the head bone alone, with no strand bones. So a
5
+ * 45° head turn swings long hair rigidly into the shoulders and chest, and
6
+ * every head movement carries the whole hairstyle like a helmet.
7
+ *
8
+ * `restHairOnShoulders` re-weights each hair vertex by its bind height — the
9
+ * head above the head bone, the upper chest at shoulder height, blended
10
+ * between — so the crown and face frame follow the head and the lengths stay
11
+ * on the body, bending in between. Skin weights only, every renderer.
12
+ *
13
+ * It is checked against the body before it is kept, because a hairstyle the
14
+ * default does not suit (a bun, a braid lying on the back, hair on a hood)
15
+ * can be pushed into the skin or clothing: tried in the head turns and nods
16
+ * gaze makes (±45° yaw, ±20° pitch), a mesh it pushes further into the body
17
+ * than its own weights did keeps its own weights.
18
+ *
19
+ * (Secondary motion — a spring swinging the ends — was tried and removed:
20
+ * hair moving on its own read worse than hair that rests.)
21
+ */
22
+ import * as THREE from 'three';
23
+ import { BodySurface, posedPoints } from './hairClearance.js';
24
+ /** Hair meshes: named as hair, drawn with alpha (cards), skinned. */
25
+ const isHairMesh = (o) => {
26
+ const m = o;
27
+ if (!m.isSkinnedMesh || !/hair/i.test(m.name))
28
+ return false;
29
+ const mat = (Array.isArray(m.material) ? m.material[0] : m.material);
30
+ return mat.transparent || mat.alphaTest > 0 || mat.alphaToCoverage;
31
+ };
32
+ /** Bind-world position of a skeleton bone. */
33
+ const bindPosition = (mesh, i) => new THREE.Vector3().setFromMatrixPosition(mesh.skeleton.boneInverses[i].clone().invert());
34
+ /**
35
+ * Re-skin hair so its lengths rest on the shoulders. Idempotent per geometry.
36
+ * Returns the number of meshes re-weighted.
37
+ */
38
+ export function restHairOnShoulders(root) {
39
+ let done = 0;
40
+ root.traverse((o) => {
41
+ if (!isHairMesh(o))
42
+ return;
43
+ const g = o.geometry;
44
+ if (g.userData.embodyHairOnShoulders)
45
+ return;
46
+ const bones = o.skeleton.bones;
47
+ const head = bones.findIndex((b) => b.name === 'head');
48
+ const chest = bones.findIndex((b) => b.name === 'spine_05' || b.name === 'spine_04');
49
+ const clavicle = bones.findIndex((b) => b.name === 'clavicle_l' || b.name === 'clavicle_r');
50
+ const index = g.attributes.skinIndex;
51
+ const weight = g.attributes.skinWeight;
52
+ if (head < 0 || chest < 0 || clavicle < 0 || !index || !weight)
53
+ return;
54
+ const top = bindPosition(o, head).y;
55
+ const shoulders = bindPosition(o, clavicle).y;
56
+ if (!(top > shoulders))
57
+ return;
58
+ const pos = g.attributes.position;
59
+ const ia = index.array;
60
+ const wa = weight.array;
61
+ const ownIndex = ia.slice();
62
+ const ownWeight = wa.slice();
63
+ const v = new THREE.Vector3();
64
+ for (let i = 0; i < pos.count; i++) {
65
+ v.fromBufferAttribute(pos, i).applyMatrix4(o.bindMatrix);
66
+ const onChest = 1 - THREE.MathUtils.smoothstep(v.y, shoulders, top);
67
+ index.setXYZW(i, head, chest, 0, 0);
68
+ weight.setXYZW(i, 1 - onChest, onChest, 0, 0);
69
+ }
70
+ const newIndex = ia.slice();
71
+ const newWeight = wa.slice();
72
+ const restWeights = () => { ia.set(ownIndex); wa.set(ownWeight); };
73
+ const useNew = () => { ia.set(newIndex); wa.set(newWeight); };
74
+ if (pushesIntoBody(root, o, bones[head], restWeights, useNew)) {
75
+ restWeights();
76
+ g.userData.embodyHairOnShoulders = 'kept own weights: resting on the shoulders pushed it into the body';
77
+ return;
78
+ }
79
+ index.needsUpdate = true;
80
+ weight.needsUpdate = true;
81
+ g.userData.embodyHairOnShoulders = true;
82
+ done++;
83
+ });
84
+ return done;
85
+ }
86
+ /** Turn `bone` by `angle` about a world axis through its own origin. */
87
+ function turnBone(bone, axis, angle) {
88
+ const parent = bone.parent ? bone.parent.getWorldQuaternion(new THREE.Quaternion()) : new THREE.Quaternion();
89
+ const turn = new THREE.Quaternion().setFromAxisAngle(axis, angle);
90
+ bone.quaternion.premultiply(parent.clone().invert().multiply(turn).multiply(parent));
91
+ }
92
+ /** The head turns and nods gaze makes: ±45° yaw, ±20° pitch. */
93
+ const GAZE_POSES = [
94
+ [new THREE.Vector3(0, 1, 0), THREE.MathUtils.degToRad(45)],
95
+ [new THREE.Vector3(0, 1, 0), THREE.MathUtils.degToRad(-45)],
96
+ [new THREE.Vector3(1, 0, 0), THREE.MathUtils.degToRad(20)],
97
+ [new THREE.Vector3(1, 0, 0), THREE.MathUtils.degToRad(-20)],
98
+ ];
99
+ /**
100
+ * Whether the new weights push `hair` further into the body than its own,
101
+ * over the gaze poses. Leaves the new weights in place and the head as found.
102
+ */
103
+ function pushesIntoBody(root, hair, head, own, next) {
104
+ root.updateMatrixWorld(true);
105
+ const box = new THREE.Box3().setFromPoints(posedPoints(hair, 7));
106
+ const body = new BodySurface(root, isHairMesh, box);
107
+ if (!body.size)
108
+ return false;
109
+ const rest = head.quaternion.clone();
110
+ let ownInside = 0;
111
+ let newInside = 0;
112
+ let points = 0;
113
+ for (const [axis, angle] of GAZE_POSES) {
114
+ turnBone(head, axis.clone().transformDirection(root.matrixWorld), angle);
115
+ root.updateMatrixWorld(true);
116
+ body.refresh();
117
+ own();
118
+ const a = posedPoints(hair);
119
+ ownInside += body.inside(a);
120
+ next();
121
+ newInside += body.inside(posedPoints(hair));
122
+ points += a.length;
123
+ head.quaternion.copy(rest);
124
+ }
125
+ root.updateMatrixWorld(true);
126
+ return newInside > ownInside + Math.max(3, points * 0.01);
127
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Whether hair goes into the body: the check behind hair's two motions, so
3
+ * either is skipped on a model where it would push hair through the skin or
4
+ * clothing.
5
+ *
6
+ * The body is every skinned mesh that is neither hair nor see-through (skin
7
+ * and clothing), sampled near the hair, each sample with its outward normal
8
+ * in the CURRENT pose (three's own skinning, `applyBoneTransform`). A point
9
+ * is inside the body by how far it lies behind the nearest sample's surface,
10
+ * looked for within `reach`. Distances are world metres: call it after the
11
+ * body is scaled.
12
+ */
13
+ import * as THREE from 'three';
14
+ /** Behind the surface by more than this counts as inside, m (skinning and sampling slack). */
15
+ export declare const INSIDE = 0.004;
16
+ export declare class BodySurface {
17
+ private readonly reach;
18
+ private readonly samples;
19
+ private pos;
20
+ private nrm;
21
+ private readonly grid;
22
+ private readonly a;
23
+ private readonly b;
24
+ /**
25
+ * @param near only body points inside this box (world) are sampled
26
+ * @param reach how far from a point the surface is looked for, m
27
+ */
28
+ constructor(root: THREE.Object3D, isHair: (m: THREE.Mesh) => boolean, near: THREE.Box3, reach?: number);
29
+ get size(): number;
30
+ /** Re-read the samples in the body's current pose. World matrices must be current. */
31
+ refresh(): void;
32
+ /** How far `p` (world) lies inside the body, m; 0 outside or with no surface near. */
33
+ depth(p: THREE.Vector3): number;
34
+ /** How many of `points` lie more than `INSIDE` inside the body. */
35
+ inside(points: readonly THREE.Vector3[]): number;
36
+ private cell;
37
+ }
38
+ /** World positions of every `stride`-th vertex of a skinned mesh, in its current pose. */
39
+ export declare function posedPoints(mesh: THREE.SkinnedMesh, stride?: number): THREE.Vector3[];
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Whether hair goes into the body: the check behind hair's two motions, so
3
+ * either is skipped on a model where it would push hair through the skin or
4
+ * clothing.
5
+ *
6
+ * The body is every skinned mesh that is neither hair nor see-through (skin
7
+ * and clothing), sampled near the hair, each sample with its outward normal
8
+ * in the CURRENT pose (three's own skinning, `applyBoneTransform`). A point
9
+ * is inside the body by how far it lies behind the nearest sample's surface,
10
+ * looked for within `reach`. Distances are world metres: call it after the
11
+ * body is scaled.
12
+ */
13
+ import * as THREE from 'three';
14
+ /** Behind the surface by more than this counts as inside, m (skinning and sampling slack). */
15
+ export const INSIDE = 0.004;
16
+ export class BodySurface {
17
+ /**
18
+ * @param near only body points inside this box (world) are sampled
19
+ * @param reach how far from a point the surface is looked for, m
20
+ */
21
+ constructor(root, isHair, near, reach = 0.03) {
22
+ this.reach = reach;
23
+ this.samples = [];
24
+ this.pos = new Float32Array(0);
25
+ this.nrm = new Float32Array(0);
26
+ this.grid = new Map();
27
+ this.a = new THREE.Vector3();
28
+ this.b = new THREE.Vector3();
29
+ root.updateMatrixWorld(true);
30
+ const box = near.clone().expandByScalar(reach);
31
+ const p = new THREE.Vector3();
32
+ root.traverse((o) => {
33
+ const m = o;
34
+ if (!m.isSkinnedMesh || !m.visible || isHair(m))
35
+ return;
36
+ const mats = Array.isArray(m.material) ? m.material : [m.material];
37
+ if (mats.every((x) => x.transparent))
38
+ return;
39
+ const g = m.geometry;
40
+ const position = g.attributes.position;
41
+ const normal = g.attributes.normal;
42
+ if (!position || !normal)
43
+ return;
44
+ for (let i = 0; i < position.count; i++) {
45
+ if (!box.containsPoint(m.getVertexPosition(i, p).applyMatrix4(m.matrixWorld)))
46
+ continue;
47
+ const at = new THREE.Vector3().fromBufferAttribute(position, i);
48
+ const n = new THREE.Vector3().fromBufferAttribute(normal, i);
49
+ // Along the normal by a share of the mesh's own size: units vary.
50
+ const step = 0.002 / (m.getWorldScale(this.a).x || 1);
51
+ this.samples.push({ mesh: m, index: i, at, out: at.clone().addScaledVector(n, step) });
52
+ }
53
+ });
54
+ this.refresh();
55
+ }
56
+ get size() {
57
+ return this.samples.length;
58
+ }
59
+ /** Re-read the samples in the body's current pose. World matrices must be current. */
60
+ refresh() {
61
+ const n = this.samples.length;
62
+ if (this.pos.length !== n * 3) {
63
+ this.pos = new Float32Array(n * 3);
64
+ this.nrm = new Float32Array(n * 3);
65
+ }
66
+ this.grid.clear();
67
+ for (let k = 0; k < n; k++) {
68
+ const s = this.samples[k];
69
+ // applyBoneTransform applies the bind matrix and its inverse itself.
70
+ const p = s.mesh.applyBoneTransform(s.index, this.a.copy(s.at)).applyMatrix4(s.mesh.matrixWorld);
71
+ const q = s.mesh.applyBoneTransform(s.index, this.b.copy(s.out)).applyMatrix4(s.mesh.matrixWorld);
72
+ q.sub(p).normalize();
73
+ p.toArray(this.pos, k * 3);
74
+ q.toArray(this.nrm, k * 3);
75
+ const key = this.cell(p.x, p.y, p.z);
76
+ let c = this.grid.get(key);
77
+ if (!c)
78
+ this.grid.set(key, (c = []));
79
+ c.push(k);
80
+ }
81
+ }
82
+ /** How far `p` (world) lies inside the body, m; 0 outside or with no surface near. */
83
+ depth(p) {
84
+ let best = this.reach * this.reach;
85
+ let hit = -1;
86
+ for (let dx = -1; dx <= 1; dx++)
87
+ for (let dy = -1; dy <= 1; dy++)
88
+ for (let dz = -1; dz <= 1; dz++) {
89
+ const c = this.grid.get(this.cell(p.x + dx * this.reach, p.y + dy * this.reach, p.z + dz * this.reach));
90
+ if (!c)
91
+ continue;
92
+ for (const k of c) {
93
+ const d = (this.pos[k * 3] - p.x) ** 2 + (this.pos[k * 3 + 1] - p.y) ** 2 + (this.pos[k * 3 + 2] - p.z) ** 2;
94
+ if (d < best) {
95
+ best = d;
96
+ hit = k;
97
+ }
98
+ }
99
+ }
100
+ if (hit < 0)
101
+ return 0;
102
+ const s = (p.x - this.pos[hit * 3]) * this.nrm[hit * 3] + (p.y - this.pos[hit * 3 + 1]) * this.nrm[hit * 3 + 1] + (p.z - this.pos[hit * 3 + 2]) * this.nrm[hit * 3 + 2];
103
+ return s < 0 ? -s : 0;
104
+ }
105
+ /** How many of `points` lie more than `INSIDE` inside the body. */
106
+ inside(points) {
107
+ let n = 0;
108
+ for (const p of points)
109
+ if (this.depth(p) > INSIDE)
110
+ n++;
111
+ return n;
112
+ }
113
+ cell(x, y, z) {
114
+ const r = this.reach;
115
+ return ((Math.floor(x / r) + 4096) * 8192 + (Math.floor(y / r) + 4096)) * 8192 + (Math.floor(z / r) + 4096);
116
+ }
117
+ }
118
+ /** World positions of every `stride`-th vertex of a skinned mesh, in its current pose. */
119
+ export function posedPoints(mesh, stride = 3) {
120
+ const out = [];
121
+ const n = mesh.geometry.attributes.position.count;
122
+ for (let i = 0; i < n; i += stride)
123
+ out.push(mesh.getVertexPosition(i, new THREE.Vector3()).applyMatrix4(mesh.matrixWorld));
124
+ return out;
125
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Normals for morphed faces that ship none.
3
+ *
4
+ * The published heads carry blend-shape positions but no blend-shape
5
+ * normals, so three lights every morphed face with its bind-pose normals.
6
+ * On a closed eye the upper-lid margin, whose normals face down into the
7
+ * socket, is pulled onto the lower lid and shades black under a key from
8
+ * above: a dark band along the lid that read as the eye standing proud.
9
+ * Mouth shapes have the same defect, less visibly.
10
+ *
11
+ * Recomputing the whole head every frame costs ~9 ms (12k vertices, 242
12
+ * targets measured), so this is sparse: once, each target's touched
13
+ * vertices (a displacement above a small share of the mesh's size) and the
14
+ * faces around every vertex; each frame, only when a weight changed, the
15
+ * positions of the touched vertices are rebuilt from the base and the active
16
+ * targets, the normals of every vertex on a face around them are recomputed
17
+ * from those positions, and vertices that relaxed since last frame get their
18
+ * base normals back. Writes go through the attribute, so interleaved exports
19
+ * are safe. Normals are in object space, as the normal attribute is; skinning
20
+ * transforms them after, as it does the positions.
21
+ *
22
+ * Meshes that ship their own morph normals, or no normals, are left alone.
23
+ * Runs after every morph writer (stage 6: lipsync, eye gaze, blink).
24
+ */
25
+ import * as THREE from 'three';
26
+ import type { FrameContext, Stage, System } from '../character/pipeline.js';
27
+ export declare class MorphNormals implements System {
28
+ readonly stage: Stage;
29
+ private readonly patches;
30
+ constructor(root: THREE.Object3D, minDelta?: number);
31
+ /** How many meshes this is correcting. */
32
+ get size(): number;
33
+ private build;
34
+ update(_dt: number, _ctx: FrameContext): void;
35
+ /** Recompute where weights changed. Returns how many vertices were rewritten. */
36
+ run(): number;
37
+ private runPatch;
38
+ }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Normals for morphed faces that ship none.
3
+ *
4
+ * The published heads carry blend-shape positions but no blend-shape
5
+ * normals, so three lights every morphed face with its bind-pose normals.
6
+ * On a closed eye the upper-lid margin, whose normals face down into the
7
+ * socket, is pulled onto the lower lid and shades black under a key from
8
+ * above: a dark band along the lid that read as the eye standing proud.
9
+ * Mouth shapes have the same defect, less visibly.
10
+ *
11
+ * Recomputing the whole head every frame costs ~9 ms (12k vertices, 242
12
+ * targets measured), so this is sparse: once, each target's touched
13
+ * vertices (a displacement above a small share of the mesh's size) and the
14
+ * faces around every vertex; each frame, only when a weight changed, the
15
+ * positions of the touched vertices are rebuilt from the base and the active
16
+ * targets, the normals of every vertex on a face around them are recomputed
17
+ * from those positions, and vertices that relaxed since last frame get their
18
+ * base normals back. Writes go through the attribute, so interleaved exports
19
+ * are safe. Normals are in object space, as the normal attribute is; skinning
20
+ * transforms them after, as it does the positions.
21
+ *
22
+ * Meshes that ship their own morph normals, or no normals, are left alone.
23
+ * Runs after every morph writer (stage 6: lipsync, eye gaze, blink).
24
+ */
25
+ import * as THREE from 'three';
26
+ /** Share of the mesh's size below which a target's move of a vertex is ignored. */
27
+ const MIN_DELTA = 2e-4;
28
+ export class MorphNormals {
29
+ constructor(root, minDelta = MIN_DELTA) {
30
+ this.stage = 6;
31
+ this.patches = [];
32
+ root.traverse((o) => {
33
+ const mesh = o;
34
+ if (!mesh.isMesh)
35
+ return;
36
+ const g = mesh.geometry;
37
+ const targets = g.morphAttributes.position;
38
+ if (!targets?.length || g.morphAttributes.normal || !g.attributes.normal || !mesh.morphTargetInfluences)
39
+ return;
40
+ this.patches.push(this.build(mesh, targets, minDelta));
41
+ });
42
+ }
43
+ /** How many meshes this is correcting. */
44
+ get size() {
45
+ return this.patches.length;
46
+ }
47
+ build(mesh, targets, minDelta) {
48
+ const g = mesh.geometry;
49
+ const position = g.attributes.position;
50
+ const normal = g.attributes.normal;
51
+ const n = position.count;
52
+ const base = new Float32Array(n * 3);
53
+ const baseNormal = new Float32Array(n * 3);
54
+ for (let i = 0; i < n; i++) {
55
+ base[i * 3] = position.getX(i);
56
+ base[i * 3 + 1] = position.getY(i);
57
+ base[i * 3 + 2] = position.getZ(i);
58
+ baseNormal[i * 3] = normal.getX(i);
59
+ baseNormal[i * 3 + 1] = normal.getY(i);
60
+ baseNormal[i * 3 + 2] = normal.getZ(i);
61
+ }
62
+ g.computeBoundingBox();
63
+ const size = g.boundingBox.getSize(new THREE.Vector3()).length() || 1;
64
+ const eps2 = (size * minDelta) ** 2;
65
+ const index = g.index ? g.index.array : null;
66
+ const faceCount = index ? index.length / 3 : n / 3;
67
+ const vert = (f, k) => (index ? index[f * 3 + k] : f * 3 + k);
68
+ const degree = new Uint32Array(n + 1);
69
+ for (let f = 0; f < faceCount; f++)
70
+ for (let k = 0; k < 3; k++)
71
+ degree[vert(f, k) + 1]++;
72
+ for (let i = 0; i < n; i++)
73
+ degree[i + 1] += degree[i];
74
+ const faceStart = degree;
75
+ const faces = new Uint32Array(faceStart[n]);
76
+ const fill = new Uint32Array(n);
77
+ for (let f = 0; f < faceCount; f++)
78
+ for (let k = 0; k < 3; k++) {
79
+ const v = vert(f, k);
80
+ faces[faceStart[v] + fill[v]++] = f;
81
+ }
82
+ const relative = g.morphTargetsRelative;
83
+ const targetVerts = [];
84
+ const targetDeltas = [];
85
+ for (const t of targets) {
86
+ const vs = [];
87
+ const ds = [];
88
+ for (let i = 0; i < n; i++) {
89
+ let dx = t.getX(i), dy = t.getY(i), dz = t.getZ(i);
90
+ if (!relative) {
91
+ dx -= base[i * 3];
92
+ dy -= base[i * 3 + 1];
93
+ dz -= base[i * 3 + 2];
94
+ }
95
+ if (dx * dx + dy * dy + dz * dz < eps2)
96
+ continue;
97
+ vs.push(i);
98
+ ds.push(dx, dy, dz);
99
+ }
100
+ targetVerts.push(Uint32Array.from(vs));
101
+ targetDeltas.push(Float32Array.from(ds));
102
+ }
103
+ return {
104
+ mesh, normal, n, base, baseNormal, pos: base.slice(), index, faceCount, faceStart, faces, targetVerts, targetDeltas,
105
+ last: new Float64Array(targets.length).fill(NaN), written: new Uint32Array(n), writtenCount: 0,
106
+ touched: new Uint8Array(n), faceFlag: new Uint32Array(faceCount), faceNormal: new Float32Array(faceCount * 3), stamp: 0,
107
+ };
108
+ }
109
+ update(_dt, _ctx) {
110
+ this.run();
111
+ }
112
+ /** Recompute where weights changed. Returns how many vertices were rewritten. */
113
+ run() {
114
+ let total = 0;
115
+ for (const p of this.patches)
116
+ total += this.runPatch(p);
117
+ return total;
118
+ }
119
+ runPatch(p) {
120
+ const w = p.mesh.morphTargetInfluences;
121
+ let changed = false;
122
+ for (let t = 0; t < w.length; t++)
123
+ if (w[t] !== p.last[t]) {
124
+ changed = true;
125
+ break;
126
+ }
127
+ if (!changed)
128
+ return 0;
129
+ p.last.set(w);
130
+ p.stamp++;
131
+ const { base, pos, touched, faceStart, faces, faceFlag, faceNormal, index } = p;
132
+ // Touched vertices: reset to base, then add every active target.
133
+ const touchedList = [];
134
+ for (let t = 0; t < w.length; t++) {
135
+ if (Math.abs(w[t]) < 1e-4)
136
+ continue;
137
+ const vs = p.targetVerts[t];
138
+ for (let k = 0; k < vs.length; k++) {
139
+ const v = vs[k];
140
+ if (!touched[v]) {
141
+ touched[v] = 1;
142
+ touchedList.push(v);
143
+ pos[v * 3] = base[v * 3];
144
+ pos[v * 3 + 1] = base[v * 3 + 1];
145
+ pos[v * 3 + 2] = base[v * 3 + 2];
146
+ }
147
+ }
148
+ }
149
+ for (let t = 0; t < w.length; t++) {
150
+ const wt = w[t];
151
+ if (Math.abs(wt) < 1e-4)
152
+ continue;
153
+ const vs = p.targetVerts[t], ds = p.targetDeltas[t];
154
+ for (let k = 0; k < vs.length; k++) {
155
+ const v = vs[k];
156
+ pos[v * 3] += ds[k * 3] * wt;
157
+ pos[v * 3 + 1] += ds[k * 3 + 1] * wt;
158
+ pos[v * 3 + 2] += ds[k * 3 + 2] * wt;
159
+ }
160
+ }
161
+ // Vertices to rewrite: every vertex of every face around a touched vertex.
162
+ const rewrite = [];
163
+ const seen = p.stamp;
164
+ for (const v of touchedList) {
165
+ for (let j = faceStart[v]; j < faceStart[v + 1]; j++) {
166
+ const f = faces[j];
167
+ if (faceFlag[f] === seen)
168
+ continue;
169
+ faceFlag[f] = seen;
170
+ for (let k = 0; k < 3; k++) {
171
+ const u = index ? index[f * 3 + k] : f * 3 + k;
172
+ if (!touched[u]) {
173
+ touched[u] = 1;
174
+ rewrite.push(u);
175
+ }
176
+ }
177
+ }
178
+ }
179
+ for (const v of touchedList)
180
+ rewrite.push(v);
181
+ // Face normals (unnormalised: area-weighted) around the rewritten vertices.
182
+ const stampFace = (f) => {
183
+ const a = index ? index[f * 3] : f * 3, b = index ? index[f * 3 + 1] : f * 3 + 1, c = index ? index[f * 3 + 2] : f * 3 + 2;
184
+ const ax = pos[a * 3], ay = pos[a * 3 + 1], az = pos[a * 3 + 2];
185
+ const bx = pos[b * 3] - ax, by = pos[b * 3 + 1] - ay, bz = pos[b * 3 + 2] - az;
186
+ const cx = pos[c * 3] - ax, cy = pos[c * 3 + 1] - ay, cz = pos[c * 3 + 2] - az;
187
+ faceNormal[f * 3] = by * cz - bz * cy;
188
+ faceNormal[f * 3 + 1] = bz * cx - bx * cz;
189
+ faceNormal[f * 3 + 2] = bx * cy - by * cx;
190
+ };
191
+ // Untouched neighbours of the rewritten set keep base positions in `pos`
192
+ // already (pos starts as the base and only touched vertices change).
193
+ const done = new Set();
194
+ for (const v of rewrite) {
195
+ let nx = 0, ny = 0, nz = 0;
196
+ for (let j = faceStart[v]; j < faceStart[v + 1]; j++) {
197
+ const f = faces[j];
198
+ if (!done.has(f)) {
199
+ stampFace(f);
200
+ done.add(f);
201
+ }
202
+ nx += faceNormal[f * 3];
203
+ ny += faceNormal[f * 3 + 1];
204
+ nz += faceNormal[f * 3 + 2];
205
+ }
206
+ const l = Math.hypot(nx, ny, nz) || 1;
207
+ p.normal.setXYZ(v, nx / l, ny / l, nz / l);
208
+ }
209
+ // Vertices written last frame that relaxed: back to their base normals.
210
+ const nowWritten = new Uint8Array(p.n);
211
+ for (const v of rewrite)
212
+ nowWritten[v] = 1;
213
+ for (let i = 0; i < p.writtenCount; i++) {
214
+ const v = p.written[i];
215
+ if (!nowWritten[v])
216
+ p.normal.setXYZ(v, p.baseNormal[v * 3], p.baseNormal[v * 3 + 1], p.baseNormal[v * 3 + 2]);
217
+ }
218
+ for (let i = 0; i < rewrite.length; i++)
219
+ p.written[i] = rewrite[i];
220
+ p.writtenCount = rewrite.length;
221
+ // Positions of touched vertices that relaxed to zero weight must return
222
+ // to base for the next frame's neighbours: reset the touched flags and
223
+ // positions of everything we touched (they are rebuilt each change).
224
+ for (const v of touchedList) {
225
+ touched[v] = 0;
226
+ }
227
+ for (const v of rewrite)
228
+ touched[v] = 0;
229
+ for (const v of touchedList) {
230
+ pos[v * 3] = base[v * 3];
231
+ pos[v * 3 + 1] = base[v * 3 + 1];
232
+ pos[v * 3 + 2] = base[v * 3 + 2];
233
+ }
234
+ p.normal.needsUpdate = true;
235
+ return rewrite.length;
236
+ }
237
+ }
@@ -31,4 +31,19 @@ export interface GazeSource {
31
31
  * already gone, so a character looking straight at you stares past you.
32
32
  */
33
33
  readonly residual: EyeGazeDirection;
34
+ /**
35
+ * Whether the character is attending to the camera this frame (tracking is
36
+ * enabled and the camera is within reach). Absent means attending.
37
+ */
38
+ readonly attending?: boolean;
39
+ /**
40
+ * The world point the character is looking at — the camera, or a brief
41
+ * glance away from it. The eyes aim here so head and eyes agree. Absent
42
+ * means the camera.
43
+ */
44
+ readonly focus?: {
45
+ x: number;
46
+ y: number;
47
+ z: number;
48
+ };
34
49
  }
@@ -4,5 +4,7 @@ export interface CharacterModelProps extends UseCharacterOptions {
4
4
  /** Called once the body is loaded and the connection is live. */
5
5
  onReady?: (character: CharacterHandle) => void;
6
6
  onError?: (error: Error) => void;
7
+ /** Cast and receive shadows on every mesh (the app's lights decide whether any are cast). Default false. */
8
+ shadows?: boolean;
7
9
  }
8
- export declare function CharacterModel({ onReady, onError, ...opts }: CharacterModelProps): import("react").JSX.Element | null;
10
+ export declare function CharacterModel({ onReady, onError, shadows, ...opts }: CharacterModelProps): import("react").JSX.Element | null;
@@ -21,8 +21,16 @@ import { jsx as _jsx } from "react/jsx-runtime";
21
21
  */
22
22
  import { useEffect, useRef } from 'react';
23
23
  import { useCharacter } from './useCharacter.js';
24
- export function CharacterModel({ onReady, onError, ...opts }) {
24
+ export function CharacterModel({ onReady, onError, shadows = false, ...opts }) {
25
25
  const { character, error } = useCharacter(opts);
26
+ useEffect(() => {
27
+ if (!character)
28
+ return;
29
+ character.root.traverse((o) => {
30
+ if (o.isMesh)
31
+ o.castShadow = o.receiveShadow = shadows;
32
+ });
33
+ }, [character, shadows]);
26
34
  // The callbacks are read through refs so that announcing is keyed on the
27
35
  // character (or error) alone. Apps pass them inline, `onReady={(c) => …}`,
28
36
  // which is a new function on every render; with the callback as an effect
@@ -26,6 +26,7 @@ import { Canvas } from '@react-three/fiber';
26
26
  import { CharacterModel } from './CharacterModel.js';
27
27
  import { OrbitRig } from './OrbitRig.js';
28
28
  import { RoomLight } from './RoomLight.js';
29
+ import { PortraitLights } from './PortraitLights.js';
29
30
  const DEFAULT_POSITION = [0, 1.45, 3.6];
30
31
  const DEFAULT_TARGET = [0, 0.92, 0];
31
32
  export function ConvaiCharacter({ style, className, background = '#17181c', camera, orbit = true, fallback = null, ...character }) {
@@ -35,10 +36,12 @@ export function ConvaiCharacter({ style, className, background = '#17181c', came
35
36
  // Transparent when background is null, so the character can sit over
36
37
  // existing page content rather than in a box of its own.
37
38
  gl: { antialias: true, alpha: background === null },
39
+ // PCF: soft shadow edges filtered by the character's own receiver.
40
+ shadows: "percentage",
38
41
  // Aim at the character's head rather than the origin at its feet;
39
42
  // r3f points the camera at [0,0,0] otherwise. Harmless when orbit is
40
43
  // on — OrbitRig takes the same target and drives the camera from
41
44
  // there — and load-bearing when it is off.
42
- onCreated: ({ camera: cam }) => cam.lookAt(...target), children: [background !== null && _jsx("color", { attach: "background", args: [background] }), _jsx("hemisphereLight", { args: [0xffffff, 0x334455, 1.4] }), _jsx("directionalLight", { position: [2, 4, 3], intensity: 2.2 }), _jsx(RoomLight, {}), orbit !== false && (_jsx(OrbitRig, { target: target, options: orbit === true ? undefined : orbit })), _jsx(Suspense, { fallback: null, children: _jsx(CharacterModel, { ...character }) })] }), fallback] }));
45
+ onCreated: ({ camera: cam }) => cam.lookAt(...target), children: [background !== null && _jsx("color", { attach: "background", args: [background] }), _jsx(PortraitLights, { target: target }), _jsx(RoomLight, { intensity: 0.2 }), orbit !== false && (_jsx(OrbitRig, { target: target, options: orbit === true ? undefined : orbit })), _jsx(Suspense, { fallback: null, children: _jsx(CharacterModel, { shadows: true, ...character }) })] }), fallback] }));
43
46
  }
44
47
  export default ConvaiCharacter;
@@ -0,0 +1,3 @@
1
+ export declare function PortraitLights({ target }: {
2
+ target: [number, number, number];
3
+ }): import("react").JSX.Element;