以指定点为中心缩放的完整图解
场景设定
目标:用户点击屏幕右侧某点,放大 2 倍,该点保持不动。
kotlin
/**
* 以指定点为中心进行缩放动画
* @param targetScale 目标缩放值(绝对值)
* @param centerX 缩放中心X(屏幕坐标)
* @param centerY 缩放中心Y(屏幕坐标)
* @param duration 动画时长
*/
fun animateScaleWithCenter(
targetScale: Float,
centerX: Float,
centerY: Float,
duration: Long = ANIMATION_DURATION,
requestCallback: () -> Unit
) {
animator?.cancel()
animator = null
val startScaleX = scaleX
val startScaleY = scaleY
val startTranslateX = translateX
val startTranslateY = translateY
// 屏幕坐标 → OpenGL 归一化坐标
val normalizedX = (centerX / mWidth) * 2f - 1f
val normalizedY = 1f - (centerY / mHeight) * 2f
// 计算缩放前该点在模型空间的位置
val pointBeforeX = (normalizedX - startTranslateX) / startScaleX
val pointBeforeY = (normalizedY - startTranslateY) / startScaleY
// 限制目标缩放范围
val clampedTargetScale = targetScale.coerceIn(MIN_SCALE, MAX_SCALE)
// 计算目标平移量
val pointAfterX = pointBeforeX * clampedTargetScale
val pointAfterY = pointBeforeY * clampedTargetScale
val targetTranslateX = normalizedX - pointAfterX
val targetTranslateY = normalizedY - pointAfterY
animator = ValueAnimator.ofFloat(0f, 1f).apply {
this.duration = duration
interpolator = DecelerateInterpolator()
addUpdateListener { animator ->
val fraction = animator.animatedValue as Float
scaleX = startScaleX + (clampedTargetScale - startScaleX) * fraction
scaleY = startScaleY + (clampedTargetScale - startScaleY) * fraction
translateX = startTranslateX + (targetTranslateX - startTranslateX) * fraction
translateY = startTranslateY + (targetTranslateY - startTranslateY) * fraction
computeMVPMatrix()
requestCallback()
}
start()
}
- 屏幕尺寸:
1080 x 1920 - 点击位置:
(810, 960)← 屏幕右侧中间 - 当前状态:
scale = 1.0, translate = 0.0 - 目标状态:
scale = 2.0, translate = ?
第一步:屏幕坐标 → 归一化坐标
坐标转换
kotlin
// 用户点击的屏幕坐标
centerX = 810f
centerY = 960f
// 转换为 OpenGL 归一化坐标
normalizedX = (810 / 1080) * 2 - 1 = 0.5
normalizedY = 1 - (960 / 1920) * 2 = 0.0
图示
结果:触摸点在归一化空间的坐标是 (0.5, 0.0)
kotlin
屏幕坐标系 OpenGL 归一化坐标系
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
(0, 0) ──────────► (1080, 0) Y ▲
│ │
│ │
│ (-1, 1) ┌┼┐ (1, 1)
│ ● (810, 960) │●│ (0.5, 0)
│ ────────┼─┼────────► X
│ (-1,-1) └┼┘ (1, -1)
▼ │
(0, 1920) ───────► (1080, 1920)
第二步:理解当前的变换状态
当前变换
kotlin
scaleX = 1.0 // 没有缩放
translateX = 0.0 // 没有平移
变换公式
归一化坐标 = 模型坐标 × scale + translate
图示:当前状态
kotlin
模型空间 变换 归一化空间
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Y ▲ Y ▲
│ │
│ │
(-1, 1) ┌┼┐ (1, 1) (-1, 1) ┌┼┐ (1, 1)
│●│ (0.5, 0) ×1.0 +0.0 │●│ (0.5, 0)
────────┼─┼────────► X ═════► ────── ┼─┼────────► X
(-1,-1) └┼┘ (1, -1) (-1,-1)└┼┘ (1, -1)
│ │
模型点 (0.5, 0) |
归一化点 (0.5, 0) |
|---|---|
| 当前状态 | 模型空间和归一化空间完全一致(因为 scale=1, translate=0) |
第三步:计算模型空间坐标(关键!)
为什么要计算?
问题:用户点击的归一化坐标 (0.5, 0) 对应的是模型空间的哪个点?
答案:需要逆向变换!
kotlin
逆向变换公式
// 正向:模型 → 归一化
normalized = model * scale + translate
// 逆向:归一化 → 模型
model = (normalized - translate) / scale
计算
pointBeforeX = (normalizedX - startTranslateX) / startScaleX
= (0.5 - 0.0) / 1.0
= 0.5
pointBeforeY = (normalizedY - startTranslateY) / startScaleY
= (0.0 - 0.0) / 1.0
= 0.0
图示:逆向变换
kotlin
归一化空间 逆向变换 模型空间
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Y ▲ Y ▲
│ │
│ │
(-1, 1) ┌┼┐ (1, 1) (-1, 1) ┌┼┐ (1, 1)
│●│ (0.5, 0) (x-0)/1.0 │●│ (0.5, 0)
────────┼─┼────────► X ◄═════ ───────┼─┼────────► X
(-1,-1) └┼┘ (1, -1) (-1,-1)└┼┘ (1, -1)
│ │
归一化点 (0.5, 0) |
模型点 (0.5, 0) |
|---|---|
| 结果 | 触摸点对应的模型空间坐标是 (0.5, 0.0) |
| 重要 | 这个 (0.5, 0.0) 是模型上的固定点,不会因为缩放而改变! |
第四步:缩放后模型点的新位置
缩放操作
kotlin
targetScale = 2.0
// 缩放后,模型点在归一化空间的新位置
pointAfterX = pointBeforeX * clampedTargetScale
= 0.5 * 2.0
= 1.0
pointAfterY = pointBeforeY * clampedTargetScale
= 0.0 * 2.0
= 0.0
图示:缩放模型点
kotlin
模型空间 缩放 ×2.0 归一化空间(无平移)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Y ▲ Y ▲
│ │
│ │
(-1, 1) ┌┼┐ (1, 1) (-1, 1) ┌┼┐ (1, 1) ●
│●│ (0.5, 0) ×2.0 │ │ (1.0, 0)
────────┼─┼────────► X ═════► ────────┼─┼────────────────► X
(-1,-1) └┼┘ (1, -1) (-1,-1)└┼┘ (1, -1)
│ │
模型点 (0.5, 0) |
缩放后 (1.0, 0) ← 超出屏幕了! |
|---|---|
| 问题 | 缩放后,模型点 (0.5, 0) 变成了归一化坐标 (1.0, 0),跑到屏幕右边缘了! |
| 期望 | 我们希望它保持在原来的位置 (0.5, 0) |
第五步:计算目标平移量
计算平移
kotlin
// 目标:让模型点保持在原来的归一化位置
targetTranslateX = normalizedX - pointAfterX
= 0.5 - 1.0
= -0.5
targetTranslateY = normalizedY - pointAfterY
= 0.0 - 0.0
= 0.0
图示:应用平移
kotlin
缩放后(无平移) 应用平移 -0.5 最终结果
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Y ▲ Y ▲ Y ▲
│ │ │
│ │ │
(-1, 1) ┌┼┐ (1, 1) ● (-1, 1) ┌┼┐ (1, 1) (-1, 1) ┌┼┐ (1, 1)
│ │ (1.0, 0) │ │● │●│ (0.5, 0)
────────┼─┼────────────► X + (-0.5) │ │(0.5, 0) ────────┼─┼────────► X
(-1,-1) └┼┘ (1, -1) ═════► │ │ (-1,-1) └┼┘ (1, -1)
│ │ │
| 点在右边缘 | 向左平移 0.5 点,回到原位置!✅ |
|---|---|
| 结果 | 通过平移 -0.5,模型点回到了原来的位置 (0.5, 0) |
第六步:验证最终变换
最终状态
kotlin
scaleX = 2.0
translateX = -0.5
验证公式
// 模型点 (0.5, 0) 经过变换后的归一化坐标
normalizedX = pointBeforeX * scaleX + translateX
= 0.5 * 2.0 + (-0.5)
= 1.0 - 0.5
= 0.5 ✅
// 确实保持在原位置!
完整变换链图示
kotlin
模型空间 缩放 ×2.0 平移 -0.5 归一化空间
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Y ▲ Y ▲ Y ▲ Y ▲
│ │ │ │
│ │ │ │
(-1,1)┌┼┐(1,1) (-1,1) ┌┼┐ (1,1) ● (-1,1)┌┼┐(1,1) (-1,1) ┌┼┐ (1,1)
│●│(0.5,0) │ │ (1,0) │●│(0.5,0) │●│(0.5,0)
──────┼─┼──────► X ─────┼─┼────────────► X ┼─┼──────► X ─────┼─┼──────► X
(-1,-1)└┼┘(1,-1) (-1,-1)└┼┘ (1,-1) (-1,-1)└┼┘(1,-1) (-1,-1)└┼┘(1,-1)
│ │ │ │
| 模型点 | 缩放后跑偏 | 平移回来 | 最终位置 |
|---|---|---|---|
| (0.5, 0) | (1.0, 0) | (0.5, 0) | (0.5, 0) ✅ |
完整流程总结
kotlin
数据流
步骤1:屏幕坐标 (810, 960)
↓ 转换
步骤2:归一化坐标 (0.5, 0.0)
↓ 逆向变换
步骤3:模型坐标 (0.5, 0.0) ← pointBefore
↓ 缩放 ×2.0
步骤4:缩放后位置 (1.0, 0.0) ← pointAfter
↓ 计算平移
步骤5:目标平移 -0.5 ← targetTranslate
↓ 应用变换
核心要点
kotlin
pointBefore 的作用
pointBefore = 触摸点在模型空间的坐标
= 模型上的固定点
= 缩放的"锚点"
为什么需要它?
模型空间的坐标是固定的,模型顶点不会因为缩放而改变,只有变换矩阵在改变,需要知道触摸点对应的模型点
通过逆向变换计算
kotlin
pointBefore = (normalized - translate) / scale
缩放后调整平移,保持模型点位置不变
缩放会改变模型点的屏幕位置
通过平移把它拉回原位置
kotlin
translate = normalized - (pointBefore * targetScale)
数学本质
kotlin
目标:normalized = pointBefore * targetScale + targetTranslate
求解:targetTranslate = normalized - pointBefore * targetScale