Files
webeditor3d/src/runtime-three/dialogue-attention-camera.ts
Victor Giers a06b6989fc auto-git:
[change] src/runtime-three/dialogue-attention-camera.ts
2026-04-25 03:42:57 +02:00

525 lines
13 KiB
TypeScript

import type { Vec3 } from "../core/vector";
export type DialogueAttentionSideSign = -1 | 1;
export interface DialogueAttentionCameraSolution {
pivot: Vec3;
position: Vec3;
lookTarget: Vec3;
sideSign: DialogueAttentionSideSign;
subjectDistance: number;
}
export interface ResolveDialogueAttentionCameraOptions {
playerFocusPoint: Vec3;
npcFocusPoint: Vec3;
referenceCameraPosition: Vec3;
referenceLookTarget: Vec3;
previousSideSign?: DialogueAttentionSideSign | null;
preferredConversationDistance?: number;
preferredConversationHeight?: number;
cameraVerticalFovRadians?: number;
cameraAspect?: number;
}
const DEFAULT_DIALOGUE_ATTENTION_DISTANCE = 3.8;
const DEFAULT_DIALOGUE_ATTENTION_HEIGHT = 0.48;
const DEFAULT_DIALOGUE_ATTENTION_VERTICAL_FOV_RADIANS = (70 * Math.PI) / 180;
const DEFAULT_DIALOGUE_ATTENTION_CAMERA_ASPECT = 16 / 9;
const MIN_DIALOGUE_ATTENTION_DISTANCE = 3.2;
const MAX_DIALOGUE_ATTENTION_DISTANCE = 7.5;
const MIN_DIALOGUE_ATTENTION_LOOK_AHEAD = 0.08;
const MAX_DIALOGUE_ATTENTION_LOOK_AHEAD = 0.28;
const MIN_DIALOGUE_ATTENTION_COMPOSITION_OFFSET = 0.03;
const MAX_DIALOGUE_ATTENTION_COMPOSITION_OFFSET = 0.14;
const MIN_DIALOGUE_ATTENTION_SHOULDER_ORBIT_RADIANS = 0.56;
const MAX_DIALOGUE_ATTENTION_SHOULDER_ORBIT_RADIANS = 0.68;
const DIALOGUE_ATTENTION_SAFE_FRAME_MAX_ABS_NDC_X = 0.74;
const DIALOGUE_ATTENTION_SAFE_FRAME_MAX_ABS_NDC_Y = 0.76;
const DIALOGUE_ATTENTION_TARGET_MIN_ABS_FOCUS_SEPARATION_NDC_X = 0.46;
const DIALOGUE_ATTENTION_PARTICIPANT_HALF_WIDTH = 0.24;
const DIALOGUE_ATTENTION_PARTICIPANT_HEAD_RISE = 0.26;
const DIALOGUE_ATTENTION_PARTICIPANT_TORSO_DROP = 0.82;
const MAX_DIALOGUE_ATTENTION_FIT_ITERATIONS = 6;
const MAX_DIALOGUE_ATTENTION_TIGHTEN_ITERATIONS = 4;
const CAMERA_SIDE_EPSILON = 1e-4;
function addVec3(left: Vec3, right: Vec3): Vec3 {
return {
x: left.x + right.x,
y: left.y + right.y,
z: left.z + right.z
};
}
function subtractVec3(left: Vec3, right: Vec3): Vec3 {
return {
x: left.x - right.x,
y: left.y - right.y,
z: left.z - right.z
};
}
function scaleVec3(vector: Vec3, scalar: number): Vec3 {
return {
x: vector.x * scalar,
y: vector.y * scalar,
z: vector.z * scalar
};
}
function dotVec3(left: Vec3, right: Vec3): number {
return left.x * right.x + left.y * right.y + left.z * right.z;
}
function crossVec3(left: Vec3, right: Vec3): Vec3 {
return {
x: left.y * right.z - left.z * right.y,
y: left.z * right.x - left.x * right.z,
z: left.x * right.y - left.y * right.x
};
}
function lengthVec3(vector: Vec3): number {
return Math.sqrt(dotVec3(vector, vector));
}
function normalizeVec3(vector: Vec3): Vec3 | null {
const length = lengthVec3(vector);
if (length <= CAMERA_SIDE_EPSILON) {
return null;
}
return scaleVec3(vector, 1 / length);
}
function clampScalar(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
function lerpVec3(start: Vec3, end: Vec3, t: number): Vec3 {
return {
x: start.x + (end.x - start.x) * t,
y: start.y + (end.y - start.y) * t,
z: start.z + (end.z - start.z) * t
};
}
function toHorizontal(vector: Vec3): Vec3 {
return {
x: vector.x,
y: 0,
z: vector.z
};
}
function rotateHorizontalRight(vector: Vec3): Vec3 {
return {
x: vector.z,
y: 0,
z: -vector.x
};
}
function createDialogueParticipantSamplePoints(
focusPoint: Vec3,
pairRight: Vec3
): Vec3[] {
const horizontalOffset = scaleVec3(
pairRight,
DIALOGUE_ATTENTION_PARTICIPANT_HALF_WIDTH
);
return [
focusPoint,
addVec3(focusPoint, horizontalOffset),
subtractVec3(focusPoint, horizontalOffset),
addVec3(focusPoint, {
x: 0,
y: DIALOGUE_ATTENTION_PARTICIPANT_HEAD_RISE,
z: 0
}),
addVec3(focusPoint, {
x: 0,
y: -DIALOGUE_ATTENTION_PARTICIPANT_TORSO_DROP,
z: 0
})
];
}
function resolveDialogueAttentionCameraPosition(
conversationMidpoint: Vec3,
pairDirection: Vec3,
pairRight: Vec3,
sideSign: DialogueAttentionSideSign,
verticalOffset: number,
shoulderOrbitRadians: number,
distance: number
): Vec3 {
const horizontalDistance = Math.sqrt(
Math.max(distance * distance - verticalOffset * verticalOffset, distance * distance * 0.45)
);
const lateralOffset = horizontalDistance * Math.sin(shoulderOrbitRadians);
const backOffset = horizontalDistance * Math.cos(shoulderOrbitRadians);
return addVec3(
addVec3(conversationMidpoint, scaleVec3(pairDirection, -backOffset)),
{
x: pairRight.x * lateralOffset * sideSign,
y: verticalOffset,
z: pairRight.z * lateralOffset * sideSign
}
);
}
function projectPointToDialogueViewNdc(
point: Vec3,
cameraPosition: Vec3,
cameraForward: Vec3,
cameraRight: Vec3,
cameraUp: Vec3,
verticalFovRadians: number,
aspect: number
) {
const relativePoint = subtractVec3(point, cameraPosition);
const depth = Math.max(dotVec3(relativePoint, cameraForward), CAMERA_SIDE_EPSILON);
const tanVerticalHalfFov = Math.tan(verticalFovRadians * 0.5);
const tanHorizontalHalfFov = tanVerticalHalfFov * aspect;
return {
x:
dotVec3(relativePoint, cameraRight) /
Math.max(depth * tanHorizontalHalfFov, CAMERA_SIDE_EPSILON),
y:
dotVec3(relativePoint, cameraUp) /
Math.max(depth * tanVerticalHalfFov, CAMERA_SIDE_EPSILON)
};
}
function measureDialogueAttentionFrame(
playerFocusPoint: Vec3,
npcFocusPoint: Vec3,
pairRight: Vec3,
cameraPosition: Vec3,
lookTarget: Vec3,
verticalFovRadians: number,
aspect: number
) {
const worldUp = {
x: 0,
y: 1,
z: 0
};
const cameraForward =
normalizeVec3(subtractVec3(lookTarget, cameraPosition)) ?? {
x: 0,
y: 0,
z: 1
};
const cameraRight =
normalizeVec3(crossVec3(cameraForward, worldUp)) ??
normalizeVec3(pairRight) ?? {
x: 1,
y: 0,
z: 0
};
const cameraUp =
normalizeVec3(crossVec3(cameraRight, cameraForward)) ?? worldUp;
const samplePoints = [
...createDialogueParticipantSamplePoints(playerFocusPoint, pairRight),
...createDialogueParticipantSamplePoints(npcFocusPoint, pairRight)
];
let maxAbsNdcX = 0;
let maxAbsNdcY = 0;
for (const point of samplePoints) {
const projected = projectPointToDialogueViewNdc(
point,
cameraPosition,
cameraForward,
cameraRight,
cameraUp,
verticalFovRadians,
aspect
);
maxAbsNdcX = Math.max(maxAbsNdcX, Math.abs(projected.x));
maxAbsNdcY = Math.max(maxAbsNdcY, Math.abs(projected.y));
}
const projectedPlayerFocus = projectPointToDialogueViewNdc(
playerFocusPoint,
cameraPosition,
cameraForward,
cameraRight,
cameraUp,
verticalFovRadians,
aspect
);
const projectedNpcFocus = projectPointToDialogueViewNdc(
npcFocusPoint,
cameraPosition,
cameraForward,
cameraRight,
cameraUp,
verticalFovRadians,
aspect
);
return {
maxAbsNdcX,
maxAbsNdcY,
focusSeparationNdcX: Math.abs(projectedNpcFocus.x - projectedPlayerFocus.x)
};
}
function resolveDialogueAttentionSideSign(
options: ResolveDialogueAttentionCameraOptions,
conversationMidpoint: Vec3,
pairRight: Vec3
): DialogueAttentionSideSign {
if (options.previousSideSign !== undefined && options.previousSideSign !== null) {
return options.previousSideSign;
}
const referenceOffset = subtractVec3(
options.referenceCameraPosition,
conversationMidpoint
);
const sideMetric = dotVec3(toHorizontal(referenceOffset), pairRight);
if (Math.abs(sideMetric) > CAMERA_SIDE_EPSILON) {
return sideMetric >= 0 ? 1 : -1;
}
const referenceForward =
normalizeVec3(
toHorizontal(
subtractVec3(
options.referenceLookTarget,
options.referenceCameraPosition
)
)
) ??
normalizeVec3(toHorizontal(subtractVec3(options.npcFocusPoint, options.playerFocusPoint))) ??
{
x: 0,
y: 0,
z: 1
};
const referenceRight = rotateHorizontalRight(referenceForward);
const facingMetric = dotVec3(referenceRight, pairRight);
if (Math.abs(facingMetric) > CAMERA_SIDE_EPSILON) {
return facingMetric >= 0 ? 1 : -1;
}
return 1;
}
export function resolveDialogueAttentionCameraSolution(
options: ResolveDialogueAttentionCameraOptions
): DialogueAttentionCameraSolution {
const pairVector = subtractVec3(options.npcFocusPoint, options.playerFocusPoint);
const subjectDistance = lengthVec3(pairVector);
const pairDirection =
normalizeVec3(toHorizontal(pairVector)) ??
normalizeVec3(
toHorizontal(
subtractVec3(
options.referenceLookTarget,
options.referenceCameraPosition
)
)
) ?? {
x: 0,
y: 0,
z: 1
};
const pairRight = rotateHorizontalRight(pairDirection);
const conversationMidpoint = lerpVec3(
options.playerFocusPoint,
options.npcFocusPoint,
0.5
);
const sideSign = resolveDialogueAttentionSideSign(
options,
conversationMidpoint,
pairRight
);
const preferredConversationDistance =
options.preferredConversationDistance ??
DEFAULT_DIALOGUE_ATTENTION_DISTANCE;
const preferredConversationHeight =
options.preferredConversationHeight ?? DEFAULT_DIALOGUE_ATTENTION_HEIGHT;
const verticalFovRadians =
options.cameraVerticalFovRadians ??
DEFAULT_DIALOGUE_ATTENTION_VERTICAL_FOV_RADIANS;
const aspect = Math.max(
options.cameraAspect ?? DEFAULT_DIALOGUE_ATTENTION_CAMERA_ASPECT,
CAMERA_SIDE_EPSILON
);
let desiredDistance = clampScalar(
preferredConversationDistance + subjectDistance * 0.18,
MIN_DIALOGUE_ATTENTION_DISTANCE,
MAX_DIALOGUE_ATTENTION_DISTANCE
);
const verticalOffset =
preferredConversationHeight + Math.min(0.42, subjectDistance * 0.05);
const shoulderOrbitRadians = clampScalar(
MIN_DIALOGUE_ATTENTION_SHOULDER_ORBIT_RADIANS + subjectDistance * 0.02,
MIN_DIALOGUE_ATTENTION_SHOULDER_ORBIT_RADIANS,
MAX_DIALOGUE_ATTENTION_SHOULDER_ORBIT_RADIANS
);
const lookTarget = addVec3(
addVec3(
conversationMidpoint,
scaleVec3(
pairDirection,
clampScalar(
subjectDistance * 0.12,
MIN_DIALOGUE_ATTENTION_LOOK_AHEAD,
MAX_DIALOGUE_ATTENTION_LOOK_AHEAD
)
)
),
addVec3(
scaleVec3(
pairRight,
-sideSign *
clampScalar(
subjectDistance * 0.03,
MIN_DIALOGUE_ATTENTION_COMPOSITION_OFFSET,
MAX_DIALOGUE_ATTENTION_COMPOSITION_OFFSET
)
),
{
x: 0,
y: 0.05,
z: 0
}
)
);
let position = resolveDialogueAttentionCameraPosition(
conversationMidpoint,
pairDirection,
pairRight,
sideSign,
verticalOffset,
shoulderOrbitRadians,
desiredDistance
);
for (let iteration = 0; iteration < MAX_DIALOGUE_ATTENTION_FIT_ITERATIONS; iteration += 1) {
const frame = measureDialogueAttentionFrame(
options.playerFocusPoint,
options.npcFocusPoint,
pairRight,
position,
lookTarget,
verticalFovRadians,
aspect
);
const widthRatio =
frame.maxAbsNdcX / DIALOGUE_ATTENTION_SAFE_FRAME_MAX_ABS_NDC_X;
const heightRatio =
frame.maxAbsNdcY / DIALOGUE_ATTENTION_SAFE_FRAME_MAX_ABS_NDC_Y;
const requiredScale = Math.max(widthRatio, heightRatio, 1);
if (requiredScale <= 1.001 || desiredDistance >= MAX_DIALOGUE_ATTENTION_DISTANCE) {
break;
}
desiredDistance = clampScalar(
desiredDistance * Math.min(requiredScale * 1.04, 1.35),
MIN_DIALOGUE_ATTENTION_DISTANCE,
MAX_DIALOGUE_ATTENTION_DISTANCE
);
position = resolveDialogueAttentionCameraPosition(
conversationMidpoint,
pairDirection,
pairRight,
sideSign,
verticalOffset,
shoulderOrbitRadians,
desiredDistance
);
}
for (
let iteration = 0;
iteration < MAX_DIALOGUE_ATTENTION_TIGHTEN_ITERATIONS;
iteration += 1
) {
const currentFrame = measureDialogueAttentionFrame(
options.playerFocusPoint,
options.npcFocusPoint,
pairRight,
position,
lookTarget,
verticalFovRadians,
aspect
);
if (
currentFrame.focusSeparationNdcX >=
DIALOGUE_ATTENTION_TARGET_MIN_ABS_FOCUS_SEPARATION_NDC_X ||
desiredDistance <= MIN_DIALOGUE_ATTENTION_DISTANCE + 1e-3
) {
break;
}
const tightenedDistance = clampScalar(
desiredDistance *
Math.max(
currentFrame.focusSeparationNdcX /
DIALOGUE_ATTENTION_TARGET_MIN_ABS_FOCUS_SEPARATION_NDC_X,
0.84
),
MIN_DIALOGUE_ATTENTION_DISTANCE,
MAX_DIALOGUE_ATTENTION_DISTANCE
);
if (tightenedDistance >= desiredDistance - 1e-3) {
break;
}
const tightenedPosition = resolveDialogueAttentionCameraPosition(
conversationMidpoint,
pairDirection,
pairRight,
sideSign,
verticalOffset,
shoulderOrbitRadians,
tightenedDistance
);
const tightenedFrame = measureDialogueAttentionFrame(
options.playerFocusPoint,
options.npcFocusPoint,
pairRight,
tightenedPosition,
lookTarget,
verticalFovRadians,
aspect
);
if (
tightenedFrame.maxAbsNdcX > DIALOGUE_ATTENTION_SAFE_FRAME_MAX_ABS_NDC_X ||
tightenedFrame.maxAbsNdcY > DIALOGUE_ATTENTION_SAFE_FRAME_MAX_ABS_NDC_Y
) {
break;
}
desiredDistance = tightenedDistance;
position = tightenedPosition;
}
return {
pivot: conversationMidpoint,
position,
lookTarget,
sideSign,
subjectDistance
};
}