Plaza 新闻汇总

使用线性代数构建交互式图表编辑器

矩阵,这是我们许多人在学校里都学过的线性代数核心概念之一。尽管它们很重要,但在我的职业生涯中却从未有机会使用它们,导致我忘记了它们有多么强大和通用。对我来说,这个时刻出现在我构建Schemio(我的交互式图表编辑器)的过程中。本文深入探讨了我是如何使用矩阵来解决一些棘手问题的,它适合所有对背后的数学原理感兴趣的人。

**从简单开始:Schemio 的早期**

当我开始构建Schemio 时,它很简单:你可以创建形状、移动它们、调整它们的大小,甚至旋转它们。每个形状都是由位置(x,y)、大小(宽度,高度)和旋转角度定义的简单区域。足够简单。表示图的数据结构看起来像这样:

但事情变得有趣起来,当我想要添加项目层次结构时——允许用户将形状相互附加并创建复杂的交互。许多矢量图形编辑器都支持分组,其中移动一个对象会自动移动组中的其他对象。但我想更多。我设想了一个图表编辑器和游戏引擎的混合体,具有丰富的动画和自定义行为。因此,我引入了项目层次结构(通过向每个对象添加“childItems”数组),交叉手指,然后开始了。

**层次结构的问题**

假设你在图表中有一个矩形。你将另一个形状附加到它上面,然后将另一个形状附加到那个上面。现在旋转矩形。如果你使用 SVG 进行渲染,这很容易——只需嵌套 SVG 元素,浏览器就会处理其余部分。但 Schemio 不仅仅是关于渲染。你可能会附加连接器、将对象安装或卸载到彼此,或执行自定义交互。对于所有这些,你需要能够在本地坐标和世界坐标之间进行转换。

我最初的方法?天真而笨拙。我迭代了对象的父链,使用简单的公式应用变换。

后来,我通过缓存父变换对其进行了优化,这在一段时间内运行良好。但很快,我遇到了两个很大的局限性:缩放和枢轴点。

**缩放和枢轴点**

缩放增加了动态调整对象尺寸的能力,而枢轴点定义了旋转中心。应用缩放的能力彻底改变了 Schemio,它能够动态加载外部图表。

我在对象区域中添加了 4 个额外的属性。以下是更新后的区域结构:

但是有了这些新要求,管理变换变得很混乱。这时我想起来了:矩阵来救援!

在 2D(和 3D)图形中,每个变换——平移、旋转、缩放——都可以使用矩阵来表示。例如,空间中的一个点是一个 3×1 矩阵。要对其进行变换,你需要用一个 3×3 变换矩阵乘以它。让我带你了解一些基础知识。

**矩阵变换 101**

从单位矩阵开始,它表示没有变换:

接下来,平移矩阵,用于偏移位置:

想要旋转?使用旋转矩阵:

对于调整大小,有缩放矩阵:

组合这些是魔力发生的地方。例如,要平移和旋转一个对象,你需要将平移和旋转矩阵相乘。缩放和枢轴点调整遵循类似的模式。

由于所有变换矩阵都是 3x3,所以 2D 点需要表示为 3x1 矩阵。当你用一个 3x3 变换矩阵乘以一个 3x1 点矩阵时,你会得到另一个 3x1 矩阵——这就是你变换后的点。这基本上就是所有这些变换的工作原理!

以下是对象的完整变换公式:

**世界坐标与本地坐标**

如你所见,我们仅仅通过应用简单的矩阵乘法就取得了一些非常酷的结果。但这并不是真正挑战的终点。当我构建 Schemio 时,棘手的部分不仅仅是应用这些变换——而是弄清楚如何在世界坐标和对象的本地坐标之间进行映射,反之亦然。

这种映射对于连接连接器或精确确定用户相对于其局部左上角在变换对象上点击的位置至关重要。我们已经知道如何从本地坐标转换为世界坐标,所以现在让我们反过来解决相反的问题:将世界点转换回对象的本地坐标。为此,我们将从我们的本地到世界公式开始:

在上面的公式中,除了本地点之外的所有内容都是已知的,所以我们可以简化并对矩阵进行分组:

在这里,矩阵 A 表示对象的整个变换,包括它自己的变换及其所有父对象的变换。现在,在线性代数中,你不能对矩阵进行除法——但有一个解决方法。你可以使用矩阵的逆矩阵。如果我们找到矩阵 A 的逆矩阵,则表示为 A⁻¹。

我不会深入探讨计算矩阵逆矩阵的细节,因为你可以在任何线性代数教科书中查阅,但是一旦我们有了 A⁻¹,我们就可以从左边用它乘以等式的两边:

矩阵逆矩阵的妙处在于:用一个矩阵乘以它的逆矩阵会得到单位矩阵,这简化了所有内容。这意味着公式变为:

就是这样!这是将世界点转换为本地坐标的公式。矩阵使这些类型的变换更有条理和更易于管理。没有它们,推导出公式将不必要地复杂和混乱。

**安装和卸载对象:层次变换的挑战**

在开发 Schemio 中的项目层次结构功能时,我面临的更棘手的问题之一是处理对象的安装和卸载。有两种方法可以做到这一点:

你可以将对象拖到场景上并将其放到另一个对象上。

或者,你可以在“项目选择器”面板中重新排列层次结构。

乍一看,这似乎很简单。只需更新项目层次结构,你就完成了,对吧?嗯,不完全是。真正的挑战是重新计算拖动对象的新的位置和旋转。为什么?因为每个对象的位置都是相对于其父对象定义的。如果你只是更改层次结构而不考虑变换差异,它看起来会像这样:

注意,当“圆角矩形”对象被放到另一个对象上时,它会尴尬地上下跳动?不理想,对吧?那么,我们如何才能正确地调整其位置和旋转以避免这种情况呢?

**步骤 1:记住拖动对象的左上角位置**

我们需要的第一件事是拖动对象在移动之前左上角的世界位置。让我们称之为 topLeftWorldPoint:

worldPointOnItem 函数实际上是使用我们之前推导出的矩阵公式实现的。

**步骤 2:调整对象的旋转**

接下来,我们需要在对象在父对象之间移动时调整其旋转。想象一下,你正在将一个项目从一个父对象转移到另一个父对象。首先,计算当前父对象的世界旋转角。由于对象的方向是相对于其父对象定义的,因此我们使用名为 worldAngleOfItem 的函数将此局部方向转换为全局方向。

这个想法很简单:找到表示项目局部 X 轴的向量,并将其映射到世界坐标。该向量与世界 X 轴之间的角度为我们提供了世界旋转角。对新父对象重复此操作并相应地调整拖动对象的旋转:

**步骤 3:保留对象的位置**

现在,我们需要确保拖动对象在移动到新父对象后停留在世界中的同一位置。为此,我实现了一个名为 findTranslationMatchingWorldPoint 的函数。此函数计算必要的平移,以便指定的局部点与所需的世界点对齐。

它是如何工作的?

为了弄清楚这一点,让我们重新审视从局部点计算世界点的公式:

在这里:

Pw(世界点)和 PL(本地点)是已知的,因为它们是函数参数。

唯一的未知数是 At,对象的平移矩阵。

将所有已知矩阵组合到一个矩阵 A 中:

由于我们不必调整对象的枢轴或缩放,所以我们只需要新的 At。为了帮助隔离它,我们再次使用矩阵求逆技巧:

让我们重命名父变换矩阵的逆矩阵以使事情更清楚:

现在,这里有个问题——我们不能对矩阵 A 使用相同的求逆技巧,因为它是一个 3x1 矩阵,只有方阵才能求逆。所以,让我们展开表达式:

在将两边所有矩阵相乘之后,我们得到:

从中,我们可以专注于相关的部分:

最后,我们得到了完整的公式:

有了这些计算,拖动对象可以无缝地过渡到其新父对象,而不会出现任何奇怪的跳跃或扭曲。这种方法不仅保留了对象的位置,还确保其方向与新层次结构完美对齐。

这是一个很好的例子,说明了如何理解和应用数学概念——例如矩阵求逆和变换——可以使像这样的复杂操作成为可能!

**总结**

我希望你喜欢这篇文章,并对我在构建 Schemio 时遇到的数学挑战有所了解。如果你对该项目感到好奇,并想更深入地了解它是如何实现的,请随时查看 GitHub 上的源代码:https://github.com/ishubin/schemio。

想要自己尝试一下?前往 https://schem.io 并开始设计你自己的交互式图表或应用程序原型。

如果你对 Schemio 背后的更多数学挑战感兴趣,还有很多可以探索的地方!从贝塞尔曲线和微积分到用于性能优化的四叉树,我很快就会分享更多见解。敬请期待!

原文地址
2024-12-18 00:12:16