在 Common Lisp 中进行图形 Livecoding
在 Common Lisp 中进行图形 Livecoding
2025-04-23
从零开始开发一个 Boids 程序,无需重启。
标签: lisp programming artsy << 前一篇 一些 Lisp,比如 Common Lisp,有一个强大的特性,在关于宏的讨论中往往被低估:能够在程序运行时重新编译它,而无需重启。为了本文的目的,并且因为它听起来很酷,我们把这种能力称为 livecoding1. 进入这个程序永不停止的奇妙世界,我们将首先简要介绍 Common Lisp 和它的一个图形框架 Sketch,然后逐步实现一个 livecoded 的 Boids 算法。
Boids!
“等等,这个 livecoding 到底是什么?”
考虑一下修改一个正在运行的应用程序(比如一个电子游戏)所需的典型工作流程。
- 停止应用程序。
- 更改代码。
- (如果是编译型语言) 等待 N 个时间单位进行完整重新编译。
- 再次启动应用程序。
- 手动调整应用程序,使其恢复到之前的状态。
- 继续。
在 livecoding 环境中,应用程序永远不会停止,从而消除了步骤 1、4 和 5。相反,小的代码更改(可以细化到重新编译单个函数)会立即反映在正在运行的程序中。步骤 3 通常是瞬间完成的,因为只需要重新编译程序中已更改的部分。从理论上讲,您可以在后台连续运行整个应用程序的同时开发它,而无需等待代码重新编译。这使得开发过程更加流畅和交互,最大限度地减少停机时间。 在 Common Lisp 中,工作流程可能如下所示:
- 对单个函数进行小的更改。
- 重新编译该函数(瞬间完成)。
- 继续。
例如,请查看 Common Lisp 和 Emacs 如何被用作 现场音乐表演 的环境。 你可以听到一个 Lisp 程序在 深空 中运行时被远程调试。 Livecoding(或热重载,或其他任何你想称呼它的名字)在其他语言中也可用,例如 Smalltalk 和 Erlang。
Sketch 的简略介绍
在深入研究 Boids 之前,让我们简单了解一下 Sketch,我们选择的 Common Lisp 图形框架。 我们将更关心大的想法,而不是代码细节,但如果您不熟悉 Common Lisp 并且想理解代码示例,那么请绕道浏览 Y 分钟学会 Common Lisp。
所以,Sketch。Sketch API 主要基于 Processing 的 API。 它的主要入口点是 defsketch
宏。 下面的代码定义了一个名为 my-sketch
的“sketch”。
(defsketch my-sketch
((width 200)
(height 200)
(n 5))
;; ...drawing code here...
)
在 sketch 的名称之后是一个绑定列表,用于定义它的状态和配置。 在这里,窗口属性 width
和 height
被设置为 200
,而 n
是我们为自己使用而添加的属性。
然后是绘图代码。 这会在 sketch 运行时循环运行,每帧运行一次。 以下代码片段在黑色背景上绘制 5 个红色圆圈,每个圆圈的半径为 10,并且位于随机位置。
(background +black+)
(loop repeat n
do (with-pen (make-pen :fill +red+)
(circle (random width) (random height) 10)))
在将背景涂成黑色之后,强大的 loop
宏用于绘制 n
个圆圈。 with-pen
宏(由 Sketch 定义)配置绘图属性,如填充颜色、笔画宽度和笔画颜色。 它接受一个“pen”对象作为参数。
这是所有代码放在一起:
(defsketch my-sketch
((width 200)
(height 200)
(n 5))
(background +black+)
(loop repeat n
do (with-pen (make-pen :fill +red+)
(circle (random width) (random height) 10))))
最后,要运行 sketch,我们编译我们的代码并在 REPL 中执行 (run-sketch 'my-sketch)
,从而得到...
...艺术。
这就是我们现在需要了解的关于 Sketch 的全部内容!
Livecoding Boids
Boids 是 1986 年提出的一种模拟鸟群的算法。 从本质上讲,它包括对模拟鸟类施加 3 种力。 引用 Wikipedia2,这些力是:
- 分离:转向以避免拥挤的本地鸟群伙伴
- 凝聚力:转向朝着本地鸟群伙伴的平均位置(质心)移动
- 对齐:转向朝着本地鸟群伙伴的平均方向移动
我们如何自己实现这一点? 首先,我们需要一个画布来绘制!
(defsketch boids
((width 400)
(height 400)
(restart-on-change nil))
(background (gray-255 230))
此代码中唯一神秘的东西是 restart-on-change
参数,它在我的 Sketch fork 中可用。 当它的值为 nil
(false)时,sketch 的状态(如 boid 位置)在我们重新编译代码时不会被重置。
在 Emacs 中编译 defsketch 形式(使用 Ctrl-C Ctrl-C 快捷方式)并在 REPL 中执行 (run-sketch 'boids)
会给我们... 🥁...一个灰色背景。 太棒了。
(注意:一切顺利的话,这个适中的窗口将在整个开发生命周期中持续运行)。
现在让我们创建一些 boid 来填充我们的世界。 我们添加一个
boid
类来存储它们的位置和速度,以及一个方便的函数 make-boid
来从 x & y 坐标创建 boid。 这些依赖于一个希望不言自明的 2d 向量的实现,这些向量是使用 vec2
函数创建的。
(defclass boid ()
((pos :initarg :pos :accessor pos)
(velocity :initarg :velocity
:initform (vec2 0 0)
:accessor velocity)))
(defun make-boid (x y)
(make-instance 'boid :pos (vec2 x y)))
对于 sketch 本身,我们在随机位置添加 20 个 boid,并将它们传递给绘图循环中的 draw-boids
函数。
(defsketch boids
((width 400)
(height 400)
(restart-on-change nil)
(boids (loop repeat 20
collect (make-boid (random width) (random height)))))
(background (gray-255 230))
(draw-boids boids))
如果我们然后重新编译 defsketch(使用 Ctrl-C Ctrl-C)...
...我们得到了一个错误! 糟糕。
之前:灰色画布。 之后:红色错误屏幕。
但当然! 我们忘记定义 draw-boids
了。 但是,程序没有崩溃,我们很快就能从这个挫折中恢复过来。
这是 draw-boids
的实现。 我们不需要深入了解它是如何工作的。 对于每个 boid,它都会进行一些笨拙的向量数学运算,以确定 boid 面向哪个方向,并绘制一个指向该方向的三角形。
(defun draw-boids (boids)
(let ((boid-width 10)
(boid-length 20))
(loop for boid in boids
do (with-slots (pos velocity) boid
(with-pen (:fill +black+)
(let* ((dir (if (zerop (v-length velocity))
(vec2 0 -1)
(v-normalise velocity)))
(p1 (v+ pos (v-rescale (/ boid-length 2) dir)))
(p2 (v+ pos
(v-rescale (- (/ boid-length 2)) dir)
(v-rescale (/ boid-width 2)
(perpendicular-anticlockwise dir))))
(p3 (v+ pos
(v-rescale (- (/ boid-length 2)) dir)
(v-rescale (/ boid-width 2)
(perpendicular-clockwise dir)))))
(polygon (vx p1) (vy p1)
(vx p2) (vy p2)
(vx p3) (vy p3))))))))
一旦我们编译 draw-boids
,错误屏幕就会消失,我们可爱的 boid 就会被绘制到位。 而且我们不必重启程序来修复它!
之前:红色错误屏幕。 之后:绘制了 boid。
有两种 Common Lisp 功能使我们能够像我们在这里所做的那样动态修复错误:
- 新编译的代码和重新编译的代码会立即加载(有时称为“热重载”)到正在运行的程序中。 这开辟了各种可能性,例如在程序运行时优化它、调整重力和背景颜色等参数以及迭代开发 GUI。
- 条件系统! 这有点像其他语言中的异常处理,但更强大。 我们不仅可以发出异常情况(“conditions”),还可以定义用于从这些情况中恢复的“restarts”。 当正在运行的 Common Lisp 程序遇到未处理的 condition 时,控制权将传递给调试器,并且用户会看到一系列 restart。 也许他们想重新编译有问题的函数并从之前的堆栈帧继续执行。 或者,也许错误是除以零,并且有问题的函数提供了一个 restart,该 restart 将除数的值替换为 1。 突然间,可能性不仅仅是让程序崩溃。
无论如何,对条件系统的有价值的讨论将占用一篇完整的博客文章。 回到 Boids!
现在我们的 boid 已正确绘制,我们希望它们移动并执行 boid 操作。 首先,我们实现一个 update-positions
函数,该函数基本上将每个 boid 的速度添加到其位置(以便 boid 移动),并应用 3 个 Boidian 力来更新 boid 的速度。 目前,实现这些力的函数已被存根。
(defun update-positions (boids)
(let ((max-velocity 10))
;; Update boid positions.
(map nil
(lambda (boid)
(setf (pos boid) (v+ (pos boid) (velocity boid))))
boids)
;; Update boid velocities.
(loop for boid in boids
do (setf (velocity boid)
(v-clamp max-velocity
(v+ (velocity boid)
(rule1 boid boids)
(rule2 boid boids)
(rule3 boid boids)))))))
;; Stubs! (For now).
(defun rule1 (boid boids)
(vec2 0 0))
(defun rule2 (boid boids)
(vec2 0 0))
(defun rule3 (boid boids)
(vec2 0 0))
然后我们必须修改绘图循环以调用 update-positions
。
(defsketch boids
((width 400)
(height 400)
(restart-on-change nil)
(boids (loop repeat 20
collect (make-boid (random width)
(random height)))))
(background (gray-255 230))
(draw-boids boids)
(update-positions boids))
到目前为止,这些更改尚未影响 boid 的行为,因此让我们回到并实现 rule-1
,它可以概括为“远离其他 boid”。 当一个 boid 与另一个 boid 的距离小于 10 像素时,我们会将它们相互推开以避免拥挤。
(defun rule1 (boid boids)
(let ((v-sum (vec2 0 0)))
(loop for boid2 in boids
for offset = (v- (pos boid) (pos boid2))
for dist = (v-length offset)
when (and (not (eq boid boid2)) (< dist 10))
do (v+! v-sum offset))
v-sum))
(注意:以 !
结尾的向量函数,如 v+!
,遵循将结果存储在作为第一个参数传递的向量中的约定)。
当我们重新编译此函数时...
...一对碰巧彼此太近的 boid 被送入虚空。 还没有反作用力将它们带回来。
接下来,我们实现 rule-2
:boid 应该朝着其他 boid 的平均位置飞行。 我们的实现可以通过仅对 boid 位置求和一次来提高效率,而不是对每个 boid 都这样做,但我懒得这样做。
(defun rule2 (boid boids)
(let ((center (vec2 0 0)))
(map nil
(lambda (boid2)
(when (not (eq boid boid2))
(v+! center (pos boid2))))
boids)
(v-scale! (/ (1- (length boids))) center)
(v-! center (pos boid))
(v-scale! (/ 200) center)
center))
重新编译 rule-2
,我们得到...
是的! 这开始看起来有点像 Boids。 让我们添加最后一条规则,rule-3
:boid 应该将其速度与所有其他 boid 的速度相匹配。 实现说明:我们可能不应该更新速度,直到计算出所有新速度,但这似乎并不重要。
(defun rule3 (boid boids)
(let ((result (vec2 0 0)))
(map nil
(lambda (boid2)
(when (not (eq boid boid2))
(v+! result (velocity boid2))))
boids)
(v-scale! (/ (1- (length boids))) result)
(v-! result (velocity boid))
(v-scale! (/ 8) result)
result))
重新编译后,我们看到 Boids 平静了一点。 由于在死亡漩涡中飞来飞去不是很像鸟类,我们也可以通过让 boid 们跟随鼠标位置来赋予它们目的。 这些更改的结果可以在帖子的顶部看到。
(defsketch boids
((width 400)
(height 400)
(restart-on-change nil)
(boids (loop repeat 20
collect (make-boid (random width)
(random height))))
(mouse-pos (vec2 200 200)))
(background (gray-255 230))
(draw-boids boids)
(update-positions boids mouse-pos))
(defmethod on-hover ((instance boids) x y)
(setf (boids-mouse-pos instance) (vec2 x y)))
(defun update-positions (boids mouse-pos)
(let ((max-velocity 10))
(map nil
(lambda (boid)
(setf (pos boid) (v+ (pos boid) (velocity boid))))
boids)
(loop for boid in boids
do (setf (velocity boid)
(v-clamp max-velocity
(v+ (velocity boid)
(rule1 boid boids)
(rule2 boid boids)
(rule3 boid boids)
(v-rescale 0.1 (v- mouse-pos (pos boid)))))))))
这样,我们就完成了 Boids 的完整实现! 冒着老生常谈的风险,我将再次强调,我们完成了整个过程,而无需重新启动我们的程序或等待代码编译的可感知时间。
结束语
我希望,通过这个关于 livecoding 的简短演示,我让您体验到了这个功能的有用性和乐趣,无论您是在开发图形应用程序还是普通的会计软件。 就像我说过的,它不是 Common Lisp 独有的,因为至少 Smalltalk 和 Erlang 具有类似的功能。 也可以通过使应用程序在检测到代码更改时自动重新启动自身,或者通过添加脚本语言来弥合不太交互的语言中的差距。 下次您等待代码重新编译所需的时间时,请帮我一个忙并问自己:如何使此工作流程更具交互性? 我怎样才能让它更……像 Common Lisp 呢?
- 请参阅 Wiki 页面,以及 interactive programming。 ↩
- 我也大量借鉴了 此网页 来进行我的实现。 谢谢,Conrad! ↩
如果您想与我联系,请发送邮件至 galligankevinp@gmail.com。