在iOS开发中,WKWebView承载的Web视频播放器开启画中画功能后,如果页面上同时存在多个悬浮的画中画窗口,用户在拖拽这些窗口时极易发生相互重叠、边界穿透以及剧烈抖动等问题。这种现象的本质在于缺乏一套完善的物理动力学模拟系统来处理多体碰撞。当多个物体同时发生碰撞时,简单的边界相交判断无法满足复杂的物理交互需求,必须引入基于约束的动力学解算方法来精确计算碰撞后的位置与速度变化。
画中画多体碰撞的痛点与动力学基础
在WKWebView的混合开发模式下,画中画窗口通常是由原生AVPictureInPictureController或者Web端的DocumentPictureInPicture API创建的独立视图层级。当这些窗口被用户拖拽时,系统默认仅处理简单的手势平移,并不会主动介入窗口间的碰撞检测。如果仅在前端通过监听touchmove事件进行简单的矩形相交判断来推开其他窗口,会导致严重的帧率下降和连锁位移。因为当一个窗口被推开时,它可能又碰到了第三个窗口,这种多米诺骨牌效应在多体碰撞中极为常见。
要彻底解决此问题,必须建立正确的动力学模型。在刚体动力学中,多体碰撞的核心在于求解约束方程。当两个画中画窗口发生碰撞时,它们在碰撞法线方向上的相对速度必须满足非穿透条件。我们需要计算冲量来修正这个相对速度。冲量的大小取决于物体的质量、碰撞恢复系数以及相对速度。在多体系统中,由于多个约束同时存在,直接求解非常困难,通常采用顺序冲量解算器进行迭代求解,确保每次迭代都在修正违反约束的速度,最终达到一个稳定的物理状态。
此外,由于画中画窗口在视觉上处于同一层级,我们还需要处理位置修正。当检测到两个窗口重叠时,除了计算速度冲量防止它们继续靠近,还必须沿着碰撞法线将它们强行分离,否则会出现视觉上的穿透。这种位置修正通常通过求解鲍姆加特稳定度因子来实现,它能在迭代过程中将穿透量逐渐消除,避免画面突变。
空间划分算法在碰撞检测中的应用
在处理多体碰撞时,如果页面上存在N个画中画窗口,传统的两两相交检测的时间复杂度为O(N的平方)。当窗口数量较多时,这种暴力检测方式会极大地消耗CPU资源,导致拖拽过程出现明显的卡顿。为了优化性能,我们需要引入空间划分算法,将时间复杂度降低到接近O(N)。在二维平面中,四叉树算法是一种非常高效的空间划分数据结构。
四叉树通过递归地将二维空间划分为四个象限来管理物体。在构建四叉树时,我们会设定一个节点容量上限。当某个象限内的画中画窗口数量超过这个上限时,该象限会继续分裂为四个子象限。在进行碰撞检测时,对于任何一个画中画窗口,我们只需要检测它所在的象限以及相邻象限内的窗口,从而大幅剔除不必要的检测对。这种机制在窗口分布稀疏时表现尤为出色。
下面是一个基于Objective-C实现的四叉树节点构建与查询逻辑的核心代码示例。通过维护一个边界矩形和子节点数组,我们可以动态地插入画中画窗口的边界信息,并在拖拽时快速查询可能发生碰撞的候选窗口集合。
#import <Foundation/Foundation.h>
#import <CoreGraphics/CoreGraphics.h>
@interface QuadTreeNode : NSObject
@property (nonatomic, assign) CGRect bounds;
@property (nonatomic, strong) NSMutableArray *objects;
@property (nonatomic, strong) QuadTreeNode *northWest;
@property (nonatomic, strong) QuadTreeNode *northEast;
@property (nonatomic, strong) QuadTreeNode *southWest;
@property (nonatomic, strong) QuadTreeNode *southEast;
@property (nonatomic, assign) NSUInteger capacity;
@end
@implementation QuadTreeNode
- (instancetype)initWithBounds:(CGRect)bounds capacity:(NSUInteger)capacity {
self = [super init];
if (self) {
_bounds = bounds;
_capacity = capacity;
_objects = [NSMutableArray array];
}
return self;
}
// 插入画中画窗口边界
- (BOOL)insertObject:(id)object withBounds:(CGRect)objBounds {
if (!CGRectIntersectsRect(_bounds, objBounds)) {
return NO;
}
if (_objects.count < _capacity) {
[_objects addObject:@{@"object": object, @"bounds": [NSValue valueWithCGRect:objBounds]}];
return YES;
}
if (!_northWest) {
[self subdivide];
}
// 递归插入子节点
[_northWest insertObject:object withBounds:objBounds] ||
[_northEast insertObject:object withBounds:objBounds] ||
[_southWest insertObject:object withBounds:objBounds] ||
[_southEast insertObject:object withBounds:objBounds];
return YES;
}
// 查询可能碰撞的候选窗口
- (void)queryRange:(CGRect)range foundObjects:(NSMutableArray *)foundObjects {
if (!CGRectIntersectsRect(_bounds, range)) {
return;
}
for (NSDictionary *obj in _objects) {
CGRect objBounds = [obj[@"bounds"] CGRectValue];
if (CGRectIntersectsRect(objBounds, range)) {
[foundObjects addObject:obj[@"object"]];
}
}
if (_northWest) {
[_northWest queryRange:range foundObjects:foundObjects];
[_northEast queryRange:range foundObjects:foundObjects];
[_southWest queryRange:range foundObjects:foundObjects];
[_southEast queryRange:range foundObjects:foundObjects];
}
}
- (void)subdivide {
CGFloat x = _bounds.origin.x;
CGFloat y = _bounds.origin.y;
CGFloat w = _bounds.size.width / 2.0;
CGFloat h = _bounds.size.height / 2.0;
CGRect nw = CGRectMake(x, y, w, h);
CGRect ne = CGRectMake(x + w, y, w, h);
CGRect sw = CGRectMake(x, y + h, w, h);
CGRect se = CGRectMake(x + w, y + h, w, h);
_northWest = [[QuadTreeNode alloc] initWithBounds:nw capacity:_capacity];
_northEast = [[QuadTreeNode alloc] initWithBounds:ne capacity:_capacity];
_southWest = [[QuadTreeNode alloc] initWithBounds:sw capacity:_capacity];
_southEast = [[QuadTreeNode alloc] initWithBounds:se capacity:_capacity];
}
@end
通过上述四叉树结构,每次拖拽事件触发时,我们只需以当前拖拽窗口的边界为查询范围,即可在极短的时间内获取到周围可能发生碰撞的窗口列表。这不仅提升了碰撞检测的效率,也为后续的动力学解算留出了充足的计算时间。
基于冲量解算的碰撞响应机制实现
获取到候选碰撞窗口后,我们需要进行精确的碰撞响应。在多体碰撞动力学中,顺序冲量法是一种被广泛采用的工业级解决方案。其核心思想是在一个时间步长内,多次迭代遍历所有碰撞接触点,对每个接触点应用冲量以修正速度。由于修正一个接触点可能会破坏另一个接触点的约束,因此需要通过多次迭代来使整个系统收敛。
在具体实现中,我们需要计算两个窗口之间的最小平移向量。当两个矩形窗口相交时,我们需要找出重叠面积最小的那个轴,并将它们沿着这个轴分离。同时,根据相对速度在这个轴上的投影,计算冲量大小。如果相对速度表明它们正在远离,则不需要施加冲量。为了防止窗口在边界处来回抖动,我们还需要引入一个碰撞恢复系数,控制反弹的能量损耗,使得拖拽过程更加符合真实物理直觉。
下面是处理多体碰撞响应的核心逻辑代码。在这段代码中,我们假设每个画中画窗口都被抽象为一个刚体对象,具有位置、速度和质量属性。通过迭代应用冲量和位置修正,解决多体穿透问题。
#include <vector>
#include <cmath>
struct Vector2 {
float x;
float y;
};
struct PiPWindow {
Vector2 position;
Vector2 size;
Vector2 velocity;
float mass;
float invMass;
};
// 计算最小平移向量
Vector2 CalculateMTV(const PiPWindow& a, const PiPWindow& b) {
float dx = (a.position.x + a.size.x / 2.0f) - (b.position.x + b.size.x / 2.0f);
float dy = (a.position.y + a.size.y / 2.0f) - (b.position.y + b.size.y / 2.0f);
float overlapX = (a.size.x + b.size.x) / 2.0f - std::abs(dx);
float overlapY = (a.size.y + b.size.y) / 2.0f - std::abs(dy);
if (overlapX < overlapY) {
return {overlapX * (dx > 0 ? 1.0f : -1.0f), 0.0f};
} else {
return {0.0f, overlapY * (dy > 0 ? 1.0f : -1.0f)};
}
}
// 解决两个窗口之间的碰撞
void ResolveCollision(PiPWindow& a, PiPWindow& b) {
Vector2 mtv = CalculateMTV(a, b);
if (mtv.x == 0.0f && mtv.y == 0.0f) return;
// 位置修正 (鲍姆加特稳定度)
float percent = 0.8f; // 修正比例
float slop = 0.01f; // 允许的穿透阈值
Vector2 correction = {
std::max(mtv.x - slop, 0.0f) / (a.invMass + b.invMass) * percent,
std::max(mtv.y - slop, 0.0f) / (a.invMass + b.invMass) * percent
};
a.position.x += correction.x * a.invMass;
a.position.y += correction.y * a.invMass;
b.position.x -= correction.x * b.invMass;
b.position.y -= correction.y * b.invMass;
// 速度冲量解算
Vector2 normal = {mtv.x != 0 ? (mtv.x > 0 ? 1.0f : -1.0f) : 0.0f,
mtv.y != 0 ? (mtv.y > 0 ? 1.0f : -1.0f) : 0.0f};
Vector2 relativeVelocity = {a.velocity.x - b.velocity.x, a.velocity.y - b.velocity.y};
float velocityAlongNormal = relativeVelocity.x * normal.x + relativeVelocity.y * normal.y;
// 如果它们正在分离,则不应用冲量
if (velocityAlongNormal > 0) return;
float e = 0.2f; // 碰撞恢复系数
float j = -(1.0f + e) * velocityAlongNormal;
j /= a.invMass + b.invMass;
Vector2 impulse = {j * normal.x, j * normal.y};
a.velocity.x += impulse.x * a.invMass;
a.velocity.y += impulse.y * a.invMass;
b.velocity.x -= impulse.x * b.invMass;
b.velocity.y -= impulse.y * b.invMass;
}
// 多体碰撞迭代解算
void SolveMultiBodyCollisions(std::vector<PiPWindow>& windows, int iterations) {
for (int i = 0; i < iterations; ++i) {
for (size_t a = 0; a < windows.size(); ++a) {
for (size_t b = a + 1; b < windows.size(); ++b) {
ResolveCollision(windows[a], windows[b]);
}
}
}
}
在上述代码中,SolveMultiBodyCollisions函数通过多次迭代来处理多体碰撞。每次迭代中,ResolveCollision函数会先进行位置修正,将重叠的窗口强行分离,然后再计算速度冲量,改变窗口的运动状态。通过调整迭代次数,可以在性能和碰撞稳定性之间取得平衡。通常情况下,5到10次迭代足以处理页面上常见的多个画中画窗口的碰撞场景。
将这套动力学模拟系统接入到WKWebView的画中画拖拽逻辑中后,无论是单窗口的边界反弹,还是多窗口的挤压推搡,都能表现出极为自然的物理效果。用户在拖拽时不再会遇到窗口穿透或卡顿的问题,整体交互体验得到了质的提升。这种基于物理引擎底层原理的解决方案,不仅适用于画中画窗口,同样可以推广到任何需要处理多体碰撞的前端UI交互场景中。