· 5 分钟阅读 · 1857 字

地表草簇挤在三角形边上:夹取重心坐标会压缩半数样本

同一片三角形地表,草簇在一条斜边上排成密线,内部却明显稀疏。生成代码已经保证每个点都在三角形内,增加数量只会让这条线更密:问题发生在把两个随机数转换为表面位置的步骤。合法坐标只能保证不越界,不能保证单位面积接收相同数量的实例。

三角形草地上边缘堆积与内部均匀散布的版画机制示意

这里使用独立 C# 数值工程 ScatterLab,固定三角形 A=(0,0)、B=(6,0)、C=(0,3),面积为9。它验证程序化植被、粒子出生面或表面装饰共用的采样核,不包含引擎实例化、地形碰撞查询与实机画面。源码入口为 TriangleScatter.cs 的 TriangleScatter.Sample,运行断言与固定输入位于 Program.cs;两者一起约束本文的结果。

越界的一半样本被压成了一条边

先取两个独立且均匀的数 u,v∈[0,1),再计算 P=A+u(B-A)+v(C-A)。这个映射覆盖的是平行四边形,只有 u+v≤1 的下半部分属于目标三角形。一个直观修补是保留 u,把 v 限制为 min(v,1-u)。点确实不再越界,但所有 u+v>1 的输入都落到了 u+v=1 上。

以 (u,v)=(0.8,0.7) 为例,夹取后成为 (0.8,0.2),输出 (4.8,0.6),正好位于 BC 边。原来占据参数方形一半面积的输入被压缩到面积为零的线段上,边缘获得非零概率质量。这不是随机种子偶然偏斜,也不能靠换一张蓝噪声表修复。采样坐标转换已经改变了分布。

正确处理可以仍然只消费两个随机数:当和大于一时,同时改为 u=1-u、v=1-v。同一固定输入折返成 (0.2,0.3),得到内部点 (1.2,0.9)。与重新抽样相比,这一步无需不定次数的循环,便于按实例索引固定消费随机数;它仍要求输入本身具有所需的独立均匀分布。

折返为什么保留面积

参数方形的上半部分经 (u,v)→(1-u,1-v) 映射到下半三角形。这个变换的雅可比行列式绝对值为一,不压缩面积;目标三角形内几乎每个点都有两个来源,一个来自原本的下半区,一个来自折返的上半区。两份相同的常密度相加,仍是常密度。

随后从参数三角形到实际三角形的线性映射,其面积缩放是常数 |det(B-A,C-A)|。它会整体拉长或压扁图案,却不会让某个局部获得额外概率。因而在单个非退化三角形上,最终密度为面积的倒数;本例就是 1/9。若推广到三维表面,相应面积因子是两条边叉积的长度,不能照搬本核的二维行列式。

完整实现如下。返回值只在验证成功后产生,调用方可先构建完整的散布候选,再替换可见实例列表。参数错误和退化面没有被静默改成某个顶点,否则坏资产会再次表现为局部堆积。

public readonly record struct Point(double X, double Y);

public static class TriangleScatter
{
    public static Point Sample(Point a, Point b, Point c, double u, double v)
    {
        if (!Finite(a) || !Finite(b) || !Finite(c) ||
            !double.IsFinite(u) || !double.IsFinite(v) ||
            u < 0 || u >= 1 || v < 0 || v >= 1)
            throw new ArgumentOutOfRangeException();
        double bx = b.X - a.X, by = b.Y - a.Y;
        double cx = c.X - a.X, cy = c.Y - a.Y;
        double area2 = bx * cy - by * cx;
        if (!double.IsFinite(area2) || Math.Abs(area2) <= 1e-12)
            throw new ArgumentException("Degenerate triangle");
        if (u + v > 1)
        {
            u = 1 - u;
            v = 1 - v;
        }
        Point result = new(a.X + u * bx + v * cx,
                           a.Y + u * by + v * cy);
        if (!Finite(result)) throw new ArithmeticException();
        return result;
    }

    private static bool Finite(Point p) =>
        double.IsFinite(p.X) && double.IsFinite(p.Y);
}

1e-12 是本探针坐标单位下的两倍面积拒绝阈值,接入资产管线时必须结合尺度重定;过大的统一阈值会拒绝小而有效的面。返回值还检查有限性,避免坐标计算溢出后进入实例缓冲。这里不把退化三角形修复为均匀分布,因为它已经没有可供面积采样的二维区域。

边缘计数比一张散点图更容易暴露错误

固定输入为 u=(i+0.5)/100、v=(j+0.5)/100,其中 i,j=0…99。它是可复算网格,不是随机性测试。程序对全部10000组输入分别运行夹取与折返,并将折返三角形沿三条中位线分成四个等面积子区,边界用明确的容差归属到中心区。

同一万组固定输入下的夹取与折返散点、边缘计数及四个等面积区域

日期:2026-10-19
工作目录:$WORK_DIR
 dotnet build .tmp/daily-labs/2026-10-19-1930/ScatterLab.csproj -c Release
 exit=0; warnings=0; errors=0; elapsed=4.39s
 dotnet run --project .tmp/daily-labs/2026-10-19-1930/ScatterLab.csproj -c Release --no-build
 exit=0; passed=18; failed=0; skipped=0; elapsed=17.8762ms
 samples=10000; inside=10000
 clamped_edge=5050; folded_edge=100
 equal_area_bins=[2500,2500,2450,2550]
 centroid≈[2.0001,1.00005]
 clamped_centroid≈[3.0,1.00005]

折返后的100个边缘点来自这个网格恰好满足 u+v=1 的对角线;在连续均匀输入下,这条线的概率为零。四区的50点差异同样来自固定网格与分区边界的对齐,不能据此声称连续分布存在2%的密度偏差。均匀性的依据是面积映射,固定数据负责检查实现是否符合推导。夹取算法多出的4950个边缘点则来自整片越界区域,随网格加密仍保留约一半的占比。

断言还覆盖原点、内部点、边界点、顶点交换、整体平移,以及负随机数、端点一、非有限参数、重复顶点和共线顶点的拒绝。折返结果的均值接近面积重心 (2,1);夹取结果的横坐标均值为3,因为它从未修正 u 的方形分布。不过重心接近不能单独证明均匀,许多对称的错误分布也有相同均值,必须同时检查局部分区和边缘质量。

单面采样正确后,多面 Mesh 的选择仍需另行约束。若先等概率选三角形,一个面积为1的面与面积为9的面会接收相同数量,单位面积密度相差九倍。要在整张表面均匀散布,应按实际采样空间中的三角形面积选面,再执行本核。模型发生非均匀缩放时,局部面积权重未必对应世界面积,不能仅在最后把点变换到世界空间就宣称密度正确。

折返解决的是单面面积分布,不保证草簇之间的最小间距,也不处理坡度、遮罩与碰撞排斥。需要稳定密度时先守住面积分布,需要避免互相重叠时再增加有明确拒绝规则的空间约束;把这两件事混在坐标夹取里,既无法控制密度,也无法解释边缘为何变成一道草墙。