DYNAMIC EXPLAINER / ARTICLE FLOW

把章节变成可单步观察的过程

01 / 13
STEP 01 / CODE / LOGIC

先说结论

命中判定应逐层缩小集合:空间粗筛只负责找“可能靠近”的对象,阵营和状态过滤负责排除不合法目标,形状精判才回答是否真正位于圆、扇形或盒形内。不要让最昂贵的精判直接面对全世界对象。

场景实体:1000
空间粗筛后:40
排除友军、死亡和无敌目标后:12
圆形或扇形精判后:3
最终命中任务只消费这 3 个 Entity

先说结论

命中判定应逐层缩小集合:空间粗筛只负责找“可能靠近”的对象,阵营和状态过滤负责排除不合法目标,形状精判才回答是否真正位于圆、扇形或盒形内。不要让最昂贵的精判直接面对全世界对象。

一次查询怎样从 1000 缩到 3

场景实体:1000
空间粗筛后:40
排除友军、死亡和无敌目标后:12
圆形或扇形精判后:3
最终命中任务只消费这 3 个 Entity

40、12、3 是教学数据,真实数量取决于场景。调试时同时记录每一层的输入与输出,才能判断问题出在空间索引、过滤规则还是几何计算。

“找出范围内的目标”不是一次判断,而是一条逐层缩小候选集的查询管线:

形状配置 + 施法者姿态
  → 世界空间查询姿态
  → 空间索引粗筛
  → 阵营与有效性过滤
  → 圆形距离 / 扇形距离与角度 / 盒形局部坐标判断
  → 最终目标集合
  → 命中任务消费

把这些步骤压成一个 IsHit 布尔值,运行快,却很难回答“为什么没命中”。更实用的调试模型应同时记录候选数量、每层计算次数和每个目标的入选或排除原因。

第一步:把本地形状放进世界

查询形状可以使用固定世界坐标,也可以跟随施法者。跟随模式下,偏移和旋转都要继承施法者姿态:

QueryPose ResolvePose(Actor caster, ShapeConfig config)
{
    if (config.UseWorldSpace)
        return new QueryPose(config.Offset, config.Rotation);

    Vector2 center =
        caster.Position +
        Rotate(config.Offset, caster.Rotation);

    float rotation =
        caster.Rotation +
        config.Rotation;

    return new QueryPose(center, rotation);
}

在平面查询中,通常只使用世界坐标的两个水平轴。垂直高度应由独立的高度规则处理,不能看到一个三维参数就假定查询也使用了三维距离。

第二步:空间粗筛只负责减少候选

圆形和扇形可以用外接圆查询附近对象;旋转盒形可以用覆盖四个角的外接圆。粗筛半径还要考虑目标自身碰撞半径:

float ResolveBroadRadius(Shape shape, float targetRadius)
{
    return shape switch
    {
        Circle c => c.Radius + targetRadius,
        Sector s => s.Radius + targetRadius,
        Box b    => Length(b.HalfExtents) + targetRadius
    };
}

空间索引返回的是候选,不是最终命中。一个位于扇形背后的目标仍可能进入扇形外接圆;一个位于盒形角外的目标也可能进入盒形外接圆。

第三步:先过滤阵营与无效对象

在精确几何之前排除自己、友方、已销毁对象或缺少空间位置的对象,能减少后续计算。

foreach (Target target in broadCandidates)
{
    if (!campRule.Accept(caster, target))
    {
        trace.Reject(target, "阵营不匹配");
        continue;
    }

    preciseCandidates.Add(target);
}

阵营规则必须明确表达“敌方、友方、仅自己”等语义。不要让一个含义不清的数字层同时承担物理碰撞层和关系阵营。

圆形:比较平方距离

圆形只关心目标中心到查询中心的平面距离。把目标半径加到技能半径上,可以让目标边缘进入范围时也算命中。

bool HitCircle(
    Vector2 point,
    Vector2 center,
    float radius,
    float targetRadius)
{
    float combined = radius + targetRadius;
    return DistanceSquared(point, center)
        <= combined * combined;
}

比较平方距离可以避免每个目标都开平方。边界使用 <= 时,刚好贴边的目标算命中。

扇形:先距离,再角度

扇形先复用圆形距离判断,再把目标方向与扇形前向做点积:

bool HitSector(
    Vector2 point,
    QueryPose pose,
    float radius,
    float angle,
    float targetRadius)
{
    if (!HitCircle(point, pose.Center, radius, targetRadius))
        return false;

    Vector2 toTarget = Normalize(point - pose.Center);
    float pointAngle =
        Acos(Clamp(Dot(pose.Forward, toTarget), -1, 1));

    float tolerance =
        Asin(Clamp(targetRadius / Max(distance, targetRadius), 0, 1));

    return pointAngle <= angle * 0.5f + tolerance;
}

目标不是数学上的点。目标半径形成角度容差,使圆形目标的边缘碰到扇形边界时也能入选。目标恰好位于扇形中心时,不存在稳定方向,应直接视为通过角度判断。

盒形:先转到局部坐标

旋转盒形在世界空间不好判断。先把目标相对盒子中心的向量旋转到盒子的局部坐标:

Vector2 local =
    RotateInverse(target.Position - box.Center, box.Rotation);

对于有半径的目标,不能只把宽高简单扩大,否则会把盒子圆角外的一小块区域错误纳入。更精确的方式是计算点到盒形的有符号距离:

bool HitBox(Vector2 local, Vector2 halfSize, float targetRadius)
{
    Vector2 q = Abs(local) - halfSize;
    float outside = Length(Max(q, Vector2.Zero));
    float inside = Min(Max(q.X, q.Y), 0);
    float signedDistance = outside + inside;

    return signedDistance <= targetRadius;
}

局部坐标也非常适合调试:看到 local.x 超宽、local.y 超深,比只看到 false 更容易理解。

第四步:命中任务消费最终集合

查询器只负责返回目标。命中任务再处理同一目标的重复命中间隔、目标数量上限、效果结算与反馈:

void ApplyHitFrame(HitContext context)
{
    results.Clear();
    catcher.QueryNonAlloc(context.Reference, results);

    foreach (Target target in results)
    {
        if (!hitInterval.Allow(target, context.Frame))
            continue;

        effects.Apply(context.Source, target);
        feedback.Play(context.Source, target);
        hitInterval.Record(target, context.Frame);
    }
}

复用结果列表并在每次查询前 Clear,可以避免热路径反复分配。持续命中任务还要在新一轮开始时清空“上次命中帧”等运行状态。

三类调试场景

本文交互实验中的尺寸、角度、坐标和目标半径都是教学场景参数:

  • 正常:多个目标分布在形状内外,观察候选集逐层缩小;
  • 边界:目标中心位于几何边缘附近,观察目标半径与 <= 如何影响结果;
  • 失败:目标进入空间粗筛,却因阵营或精确几何失败而被排除。

拖动施法者时,本地形状的中心与朝向同步变化;拖动目标时,世界坐标、局部坐标、距离、角度和入选原因实时更新。

计算次数如何观察

一次查询至少记录四个数字:

  1. 场景中可观察对象数;
  2. 空间索引返回的粗筛候选数;
  3. 通过阵营与有效性过滤的目标数;
  4. 实际执行精确几何判断的次数。

粗筛的价值不是让精确判断消失,而是让它只发生在少量候选上。若粗筛候选始终接近全场对象数,应该检查空间分区粒度或查询半径,而不是继续微优化点积。

边界与失效条件

零尺寸

半径、角度、盒形半宽或半深接近零时,查询应明确返回空集合,不要悄悄改成另一种形状。

无法解析姿态

本地空间查询依赖施法者的位置与旋转。引用失效时应直接暴露姿态解析失败,不能在世界原点继续查询。

垂直维度

平面查询会忽略高度差。如果设计需要空中与地面分层,必须增加独立高度过滤,或改用真正的三维查询。

高速移动

离散帧查询可能漏掉两个采样点之间的目标。可用上一帧中心到当前帧中心的胶囊轨迹补扫,但这会扩大候选并增加去重成本。

目标半径

使用配置半径、运行时碰撞半径还是统一最小半径,必须有单一规则。半径会同时改变粗筛距离和精确边界,不能只在其中一层使用。

复杂度

设空间粗筛返回 k 个候选:

  • 粗筛成本取决于空间索引;
  • 阵营过滤是 O(k);
  • 圆形、扇形、盒形对每个候选都是 O(1);
  • 精确阶段总成本为 O(k);
  • 结果列表复用可把每次查询的额外分配降到接近零。

最值得优化的通常是 k,最值得展示的则是每个目标从哪一层被排除。

最后记住

先把本地形状转换到世界空间。
空间粗筛只减少候选,不能代替精确命中。
阵营、生命和免疫规则应在精判前过滤。
最终结果要保留入选或拒绝原因,方便调试。