auto-git:

[change] src/runtime-three/dialogue-attention-camera.ts
This commit is contained in:
2026-04-25 03:42:51 +02:00
parent 4f6e587e79
commit a06b6989fc

View File

@@ -18,10 +18,14 @@ export interface ResolveDialogueAttentionCameraOptions {
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;
@@ -30,6 +34,14 @@ 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 {
@@ -60,6 +72,14 @@ 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));
}
@@ -102,6 +122,157 @@ function rotateHorizontalRight(vector: Vec3): Vec3 {
};
}
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,
@@ -181,26 +352,25 @@ export function resolveDialogueAttentionCameraSolution(
DEFAULT_DIALOGUE_ATTENTION_DISTANCE;
const preferredConversationHeight =
options.preferredConversationHeight ?? DEFAULT_DIALOGUE_ATTENTION_HEIGHT;
const desiredDistance = clampScalar(
preferredConversationDistance + subjectDistance * 0.42,
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 horizontalDistance = Math.sqrt(
Math.max(
desiredDistance * desiredDistance - verticalOffset * verticalOffset,
desiredDistance * desiredDistance * 0.45
)
);
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 lateralOffset = horizontalDistance * Math.sin(shoulderOrbitRadians);
const backOffset = horizontalDistance * Math.cos(shoulderOrbitRadians);
const lookTarget = addVec3(
addVec3(
conversationMidpoint,
@@ -230,20 +400,123 @@ export function resolveDialogueAttentionCameraSolution(
}
)
);
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: addVec3(
addVec3(
conversationMidpoint,
scaleVec3(pairDirection, -backOffset)
),
{
x: pairRight.x * lateralOffset * sideSign,
y: verticalOffset,
z: pairRight.z * lateralOffset * sideSign
}
),
position,
lookTarget,
sideSign,
subjectDistance