From a06b6989fcf3051b4e574aa44c67b9088ab1d326 Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Sat, 25 Apr 2026 03:42:51 +0200 Subject: [PATCH] auto-git: [change] src/runtime-three/dialogue-attention-camera.ts --- .../dialogue-attention-camera.ts | 315 ++++++++++++++++-- 1 file changed, 294 insertions(+), 21 deletions(-) diff --git a/src/runtime-three/dialogue-attention-camera.ts b/src/runtime-three/dialogue-attention-camera.ts index 12914ec7..7b884e8f 100644 --- a/src/runtime-three/dialogue-attention-camera.ts +++ b/src/runtime-three/dialogue-attention-camera.ts @@ -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