《HTML5 + JavaScript 动画基础》读书笔记

@JChehe 2019-09-11 03:10:53发表于 JChehe/blog

本书的目录就激起了笔者强烈的阅读欲望。每阅读一章或一节,几乎都能解答之前遇到的疑惑,也开阔了知识面,是一本难得的好书,相见恨晚。笔者也相信,丰富的随书案例能在创作时激发灵感。

本文仅记录了部分知识点,感兴趣的读者可以深入阅读书本。

原书案例>>

第一部分 JavaScript 动画基础

第三章 三角学

勾股定理:

用于直角三角形,直角两条边的平方和等于斜边的平方。

已知两点计算长度

a^2 + b^2 = c^2

三角函数

在直角三角形中,

sinθ = a / c(对边/斜边)
cosθ = b / c(邻边/斜边)
tanθ = a / b(对边/邻边)
cotθ = b / a(领边/对边)

另外,一个角的正弦值等于另一个角的余弦值。

反三角函数

是三角函数的逆运算。换句话说,输入一个比率,获得对应的夹角(弧度)。

反正切函数 用于计算夹角(弧度)。

弧度与角度

2PI弧度 = 360度
1弧度 = 57.2958度

function degToRad (deg) {
    return deg * Math.PI / 180
}
function radToDeg (rad) {
    return rad * 180 / Math.PI
}

坐标系类型

直角坐标系(又叫笛卡尔坐标系) (x, y)

极坐标系 (r, θ)

其中:

r = Math.sqrt(x^2 + y^2)  
θ = Math.atan2(y, x)

正弦曲线公式:y = Asin(Bx + C) + D

  • A 控制振幅,A 值越大,波峰和波谷越大,A 值越小,波峰和波谷越小;
  • B 值会影响周期,B 值越大,那么周期越短,B 值越小,周期越长。
  • C 值会影响图像左右移动,C 值为正数,图像右移,C 值为负数,图像左移。
  • D 值控制上下移动。

正弦曲线

余弦定理

余弦定理是描述三角形中三边长度与一个角的余弦值关系的数学定理,是勾股定理在一般三角形情形下的推广,勾股定理是余弦定理的特例。余弦定理是揭示三角形边角关系的重要定理,直接运用它可解决一类已知三角形两边及夹角求第三边或者是已知三个边求三角的问题。——百度百科

对于任意三角形,任何一边的平方等于其他两边平方的和减去这两边与它们夹角的余弦的积的两倍。

a² = b² + c² - 2bc x cosA
b² = a² + c² - 2ac x cosB
c² = a² + b² - 2ab x cosC

三角函数特性:

  • 周期性
  • 平滑性
  • 缓动性

更多三角公式

三角函数公式

第四章 贝塞尔曲线

二次贝塞尔曲线

context.quadraticCurveTo(cpx, cpy, x, y)

第一个是控制点,第二个是终点。

该函数会计算出从一个点到另一个点的一条曲线。该曲线会弯向但永远不触及控制点,就好像它被控制点的引力所吸引。

穿过控制点的曲线

假设起点为 (x0, y0)、终点为 (x2, y2),控制点为 (x1, y1)。假如我们希望曲线穿过的点为 (xt, yt),那么 (x1, y1) 应该设为什么值呢?下面是计算公式:

x1 = xt * 2 - (x0 + x2) / 2
y1 = yt * 2 - (y0 + y2) / 2

简单来说,将目标点的坐标乘以 2 再减去起点和终点坐标的平均值。

弯向多个方向的平滑曲线

错误做法

开始一条新路径,把画笔移到第一个点的位置。接下来 for 循环从 1 开始以 2 为步长递增,绘制一条曲线经过点 1 到达点 2,然后经过点 3 到达点 4,在经过点 5 到达点 6,最后经过点 7 到达点 8,而它恰好是最后一个点(注:点 0 为第一个点)。在该案例中,至少要包含三个点,而且点的个数必须为奇数。

...
for (let i = 0; i < numPoints; i++) {
    points.push({
        x: Math.random() * canvas.width,
        y: Math.random() * canvas.height
    })
}

context.beginPath()
context.moveTo(points[0].x, points[1].y)

for (let i = 0; i < numPoints; i += 2) {
    context.quadraticCurveTo(points[i].x, points[i].y, points[i + 1].x, points[y + 1].y)
}

context.stroke()
...

Joined Multiple Curves

如上图所示,它根本就不像一条平滑的曲线,而且在某些地方看上去还很尖锐。问题在于在连续两条曲线间并没有协调好它们的走向,而只是简单地穿过了同一个点。

正确做法

需插入一些更多的点让它看上去更像曲线。在每两个点之间,加入一个恰好位于它们中间的新点,并使用它们作为每条曲线的起点和终点,而将原始点作为曲线的控制点。

...
context.beginPath()
context.moveTo(points[0].x, points[1].y)

//curve through the rest, stopping at each midpoint
for (i = 1; i < numPoints - 2; i++) {
    ctrlPoint.x = (points[i].x + points[i+1].x) / 2
    ctrlPoint.y = (points[i].y + points[i+1].y) / 2
    context.quadraticCurveTo(points[i].x, points[i].y,
        ctrlPoint.x, ctrlPoint.y)
}
//curve through the last two points
context.quadraticCurveTo(points[i].x, points[i].y,
    points[i+1].x, points[i+1].y)
context.stroke()
...

Smoothly Joined Multiple Curves

上述代码中,for 循环从 1 开始到 numPoints - 2 结束,这也就略过第一个和最后一个点。在循环中创建一个新的控制点,其 x、y 坐标分别设置为循环中当前点和后续点的 x、y 坐标的平均值。然后绘制一条穿过当前点并以控制点结尾的曲线,重复此过程直到循环结束。当循环结束时,索引 i 指向倒数第二个点,此时绘制一条曲线穿过它到达最后一个点。

此方法并不限制点的个数是否为奇数,只需点的个数等于或大于 3 个即可。

闭合的平滑曲线

与上面方法相同。它计算初始中点,即第一个点和最后一个点的平均值,并移动到该位置,然后遍历剩下的点,获得接下来每两个相邻点的中点,最终将最后一条曲线画回到初始的中点。

...
for (let i = 0; i < numPoints; i++) {
    points.push({
        x: Math.random() * canvas.width,
        y: Math.random() * canvas.height
    })
}

//find the first midpoint and move to it
ctrlPoint1.x = (points[0].x + points[numPoints-1].x) / 2
ctrlPoint1.y = (points[0].y + points[numPoints-1].y) / 2

context.beginPath()
context.moveTo(ctrlPoint1.x, ctrlPoint1.y)

//curve through the rest, stopping at each midpoint
for (i = 0; i < numPoints - 1; i++) {
    ctrlPoint.x = (points[i].x + points[i+1].x) / 2
    ctrlPoint.y = (points[i].y + points[i+1].y) / 2
    context.quadraticCurveTo(points[i].x, points[i].y,
        ctrlPoint.x, ctrlPoint.y)
}
//curve through the last point, back to the first midpoint
context.quadraticCurveTo(points[i].x, points[i].y,
    ctrlPoint1.x, ctrlPoint1.y)
context.stroke()
...

Smoothly Joined Multiple Curves in Closed Path

其它形式的曲线(原生 API)

  • bezierCurveTo(cp1x, co1y, cp2x, cp2y, x, y) 三次贝塞尔曲线
  • arcTo(cp1x, cp1y, cp2x, cp2y, radius) 根据控制点和半径绘制圆弧路径,使用当前的描点(前一个moveTo或lineTo等函数的止点)。根据当前描点与给定的控制点1连接的直线,和控制点1与控制点2连接的直线,作为使用指定半径的圆的切线,画出两条切线之间的弧线路径。
  • arc(x, y, radius, startAngle, endAngle[, antiClockwise]) 圆。

第二部分 基本动画

第五章 速度向量和加速度

处理多个合力:只需将每个力产生的加速度叠加到速度向量上即可,其中并不涉及复杂的加权平均或因式分解计算。

  • 将速度向量分解为 x、y 轴上的向量分量。
vx = v * Math.cos(angle)
vy = v * Math.sin(angle)
  • 将加速度分量(作用在物体上的力)分解为 x、y 轴上的向量分量。
ax = force * Math.cos(angle)
ay = force * Math.sin(angle)
  • 将加速度加入速度向量
vx += ax
vy += ay
  • 将速度分量加入位置坐标
x += vx
y += vy

第六章 摩擦力

摩擦力:(又称为阻力、阻尼)只会改变速度向量的大小而不会改变它的方向。换句话说,摩擦力只能将物体的速度降至零,但它无法让物体掉头向相反的方向移动。

正确的方式

speed = Math.sqrt(vx * vx + vy * vy)
angle = Math.atan2(vy, vx)
if (speed > friction) {
    speed -= friction
} else {
    speed = 0
}

vx = Math.cos(angle) * speed
vy = Math.sin(angle) * speed

简便的方式

vx *= friction
vy *= friction

可基于 Spaceship Simulation with Friction Applied 案例做一款可漂移的小车游戏。

第三部分 高级动画

第八章 缓动与弹动

缓动和弹动关系紧密,这两种技术都是把对象从已有位置移动到目标位置的方法。缓动指物体滑动到目标点就停下来。弹动是指物体来回反弹一会,最终停在目标点的运动。

两种技术的共同点:

  • 需要设定一个目标点。
  • 需要确定物体到目标点的距离。
  • 运动和距离是成正比的 —— 距离越远,运动的程度越大。

缓动和弹动的不同点:

  • 运动和距离成正比的方面(或方式)不一样。
    • 缓动是速度与距离成正比:物体距离目标点越远,物体运动速度越快。当物体运动到很接近目标点的时候,它几乎就停下来了。
    • 弹动是加速度与距离成正比:物体离目标点越远,物体加速度越大。当物体很接近目标点的时候,加速度变得很小,但它还是在加速。当它越过目标点之后,随着距离的变大,反向加速度也随之变大,就会把它拉回来。最终在摩擦力的作用下停住。

缓动

缓动有多种类型,下面主要讨论“缓出(ease out)”。

延伸:ease in、ease out、ease in out 有何不同?

linear:没有任何缓动。
linear

ease in:即缓动发生在入口处,也就是刚开始的时候。
ease in

ease out:即缓动发生在出口处,也就是结束之前。
ease out

ease in out:入口和出口处都有缓动了。
ease in out

实现策略:

  • 为运动确定一个比例系数,这是一个小于 1 且大于 0 的小数(easing)。
  • 确定目标点。
  • 计算出物体与目标点的距离。
  • 计算速度、速度 = 距离 x 比例系数
  • 用当前位置加上速度来计算新的位置。
  • 重复第 3 步到第 5 步,知道物体达到目标。

实现代码:

object.x += (targetX - object.x) * easing

甚至也可以移动目标点,如鼠标。

缓动不仅于运动

  1. 旋转:object.rotation += (targetRotation - rotation) * easing
  2. 颜色
  3. 透明度
  4. ...

高级缓动

罗伯特·皮诺(Robert Penner)收集了许多缓动公式并加以分类,同时在 Flash 中实现。可在 http://robertpenner.com/easing/ 中找到他的这些缓动公式。这里有书作者整理的 JavaScript 版本:http://lamberta.github.io/html5-animation/xtras/easing-equations/click-to-ease.html。

弹动

弹动是动画编程中最重要和最强大的物理概念之一。

生活案例

在橡皮筋的一头系上一个小球,另一头固定起来。小球的目标点就是它静止悬空的那个点。将小球拉开一小段距离然后松开,刚松手那一瞬间,它的速度为零,但是橡皮筋给它施加了外力,把它拉向目标点。如果将小球拉得越远,橡皮筋对它施加的外力就越大。松手后,小球会急速飞过目标点,这时它的速度很高。但是,当它飞过目标点后,橡皮筋又把它往回拉,使其速度减小。它飞得越远,橡皮筋施加的力就越大。最终,它的速度降为零,又掉头往回飞。反复几次后,小球逐渐慢下来,停在目标点上。

实现代码:

const ax = (targetX - object.x) * spring
const ay = (targetY - object.y) * spring

vx += ax
vy += ay

// 增加摩擦力,否则一直来回弹动,永远停不下来
vx *= friction
vy *= friction

object.x += vx
object.y += vy

有偏移量的弹动

const dx = object.x - targetX
const dy = object.y - targetY

const angle = Math.atan2(dy, dx)
const targetX = targetX + Math.cos(angle) * springLength
const targetY = targetY + Math.sin(angle) * springLength

// 与以上代码相同

第九章 碰撞检测

多物体的碰撞检测策略

基础的多物体碰撞检测

多物体碰撞并不是直接的两层 for 循环,因为这其中含有不必要,甚至是重复的判断。

objects.forEach((objectA, i) => {
    for (let j = 0; j < objects.length; j++) {
        const objectB = objects[j]
        if (hitTestObject(objectA, objectB)) {
            // do something
        }
    }
})

以上碰撞检测存在以下问题:

  1. 存在自己与自己的碰撞检测,即 ij 相等时。这浪费了 objects.length 次。
  2. 任意两个对象都判断了两次。这浪费了 (objects.length * objects.length) - objects.length 次,而且由于重复判断可能会导致不可预料的结果,难以调试。

优化后的算法:

objects.forEach((objectA, i) => {
    for (let j = i + 1; j < objects.length; j++) {
        const objectB = objects[j]
        if (hitTestObject(objectA, objectB)) {
            // do something
        }
    }
})

快速计算多个物体的检测次数,其实是一个阶加。

因此,当有 n 个物体进行碰撞检测,那么判断的次数为 (n - 1)?。如 6 个物体,则判断 15 次。

阶加的概念:所有小于及等于该数的正整数之和,记作 n? 或 Σ(n)。

举例来说:5? = 1+2+3+4+5 = 15

阶加的计算方法:

n? = n(n+1) / 2

第十章 坐标旋转与斜面反弹

简单坐标旋转

通过增减角度,用基本的三角函数计算位置,就能使物体围绕中心点旋转。可以设置一个变量 vr(旋转速度)来控制角度的变化量。

vr = 0.1
angle = 0
radius = 100
centerX = 0
centerY = 0

// 在动画循环中做以下计算:
object.x = centerX + cos(angle) * radius
object.y = centerY + sin(angle) * radius

可见,这种方式需要知道角度和半径。

对于只知道物体位置和中心点位置的情况,也可以通过以下方式得到上述两个变量:

const dx = ball.x - centerX
const dy = ball.y - centerY
const angle = Math.atan2(dy, dx)
const radius = Math.sqrt(dx * dx + dy * dy)

这种方式并不适用于需要旋转多个物体,且它们相对于中心点的位置各不相同的情况。因为,要在每帧中计算每个物体的距离、角度和半径,然后再把 vr 累加在角度上,最后计算新的 x、y 坐标。显然,这既不是一个优雅的方案,也并不高效。

高级坐标旋转

如果物体围绕一个点旋转,你只知道它们的坐标,那么下面这个公式非常适合这种情况。这个方式只需要知道物体相对于中心点的 x、y 坐标和旋转角度,就能算出物体旋转后的 x、y 位置。公式如下:

x1 = x * cos(rotation) - y * sin(rotation)
y1 = y * cos(rotation) + x * sin(rotation)

公式的结果如下图所示:正在旋转 x、y 坐标,更具体地说,是旋转物体相对于中心点的坐标。所以,也可以把公式写成这样:

x1 = (x - centerX) * cos(rotation) - (y - centerY) * sin(rotation)
y1 = (y - centerY) * cos(rotation) + (x - centerY) * sin(rotation)

旋转角度(rotation)就是物体在这一步的旋转量,而不是当前角度,也不是旋转后的角度,而是两者的差值。因此,该公式不用知道和关心起始角度和旋转后的角度,只需要关心旋转角度即可。

上述公式的推导过程:

// 中心点为 (0, 0),即 centerX 和 centerY 为 0
x = radius * cos(angle) + centerX
y = radius * sin(angle) + centerY

x1 = radius * cos(angle + rotation)
y1 = radius * sin(angle + rotation)

又因为两角之和的余弦值和正弦值:

cos(a + b) = cos(a) * cos(b) + sin(a) * sin(b)
sin(a + b) = sin(a) * cos(b) + cos(a) * sin(b)

把 x1、y1 的公式展开,得到:

x1 = radius * cos(angle) * cos(rotation) - radius * sin(angle) * sin(rotation)
y1 = radius * sin(angle) * cos(rotation) + radius * cos(angle) * sin(rotation)

把上面的 x、y 变量带入公式,得到:

x1 = x * cos(rotation) - y * sin(rotation)
y1 = y * cos(rotation) + x * sin(rotation)

以旋转多个物体为例,对比两者的情况:

简单坐标旋转

balls.forEach((ball) => {
    const dx = ball.x - centerX
    const dy = ball.y - centerY
    const angle = Math.atan2(dy, dx)
    const dist = Math.sqrt(dx * dx + dy * dy)
    
    angle += vr
    ball.x = centerX + Math.cos(angle) * dist
    ball.y = centerY + Math.sin(angle) * dist
})

高级坐标旋转

const cos = Math.cos(vr)
const sin = Math.sin(vr)

balls.forEach((ball) => {
    const x1 = ball.x - centerX
    const y1 = ball.y - centerY
    const x2 = x1 * cos - y1 * sin
    const y2 = y1 * cos + x1 * sin
    
    ball.x = centerX + x2
    ball.y = centerY + y2
})

前者在每次循环中调用了 4 次 Math 方法,而后者全程只调用了 2 次,且无论多少个对象。当然这是在旋转速度不变的前提下。

斜面反弹

对于斜面,我们要做的是:旋转整个系统使反弹面水平,然后做反弹,最后再旋转回来。这意味着反弹面、物体的坐标位置和速度向量都旋转了。

旋转速度可能听起来很复杂,但是你已经把速度存储在 vx 和 vy 变量中。vx 和 vy 确定了一个包含了角度(方向)和大小(长度)的向量。如果你知道角度,就可以直接旋转它。但是如果你只知道 vx 和 vy,就可以使用高级坐标旋转公式得到同样的效果。

斜面旋转前后

从图 10-5 来看,实现反弹非常简单。只需调整小球的位置,改变 y 轴上的速度,如图 10-6 所示。

此处输入图片的描述

现在小球的位置和速度都发生了变化。接下来,再把整个场景旋转回到最初的角度,如图 10-7 所示。

以上就是斜面碰撞背后的理论。

执行旋转

在开始之前,首先需要一个东西来充当斜面。这只是为了让你能看到,而不是数学计算上的需要。对于平面反弹,可以使用 canvas 的边界。但是对于斜面反弹,可以画一条斜线,这样你就能看到小球在哪里反弹。

因此,新建一个 Line 类,可以用来画一条直线。它还提供了一个相当完善的 getBounds 方法用来做碰撞检测——即使旋转了也能正常工作。

function Line (x1, y1, x2, y2) {
    this.x = 0
    this.y = 0
    this.x1 = (x1 === undefined) ? 0 : x1
    this.y1 = (y1 === undefined) ? 0 : y1
    this.x2 = (x2 === undefined) ? 0 : x2
    this.y2 = (y2 === undefined) ? 0 : y2
    this.rotation = 0
    this.scaleX = 1
    this.scaleY = 1
    this.lineWidth = 1
}

Line.prototype.draw = function (context) {
    context.save()
    context.translate(this.x, this.y)
    context.rotate(this.rotation)
    context.scale(this.scaleX, this.scaleY)
    context.beginPath()
    context.moveTo(this.x1, this.y1)
    context.lineTo(this.x2, this.y2)
    context.closePath()
    context.stroke()
    context.restore()
}

Line.prototype.getBounds = function () {
    if (this.rotation === 0) {
        const minX = Math.min(this.x1, this.x2)
        const minY = Math.min(this.y1, this.y2)
        const maxX = Math.max(this.x1, this.x2)
        const maxY = Math.max(this.y1, this.y2)

        return {
            x: this.x + minX,
            y: this.y + minY,
            width: maxX - minX,
            height: maxY - minY
        }
    } else {
        const sin = Math.sin(this.rotation)
        const cos = Math.cos(this.rotation)
        const x1r = cos * this.x1 - sin * this.y1 // 注:书本上是 +,本案例中,结果一致
        const x2r = cos * this.x2 - sin * this.y2 // 注:书本上是 +,本案例中,结果一致
        const y1r = cos * this.y1 + sin * this.x1
        const y2r = cos * this.y2 + sin * this.x2

        return {
            x: this.x + Math.min(x1r, x2r),
            y: this.y + Math.min(y1r, y2r),
            width: Math.max(x1r, x2r) - Math.min(x1r, x2r),
            height: Math.max(y1r, y2r) - Math.min(y1r, y2r)
        }
    }
}

继续使用 Ball 类,确保小球位于直线的上方,这样它就能落在直线上。

const canvas = document.getElementById('canvas')
const context = canvas.getContext('2d')
const ball = new Ball()
const line = new Line(0, 0, 300, 0)
const gravity = 0.2
const bounce = -0.6

ball.x = 100
ball.y = 100

line.x = 50
line.y = 200
line.rotation = 10 * Math.PI / 180 // 10° 的弧度

// 计算角度的 sine 和 cosine
const cos = Math.cos(line.rotation)
const sin = Math.sin(line.rotation)

(function drawFrame () {
    window.requestAnimationFrame(drawFrame, canvas)
    context.clearRect(0, 0, canvas.width, canvas.height)
    
    // 常规的运动代码
    ball.vy += gravity
    ball.x += ball.vx
    ball.y += ball.vy
    // 以 line 为参考,获取 ball 的位置
    let x1 = ball.x - line.x
    let y1 = ball.y - line.y
    // 旋转坐标
    const x2 = cos * x1 + sin * y1
    const y2 = cos * y1 - sin * x1
    // 旋转速度
    const vx1 = cos * ball.vx + sin * ball.vy
    const vy1 = cos * ball.vy - sin * ball.vx
    // 执行旋转后的反弹
    if (y2 > -ball.radius) {
      y2 = -ball.radius
      vy1 *= bounce
    }
    // 恢复旋转前
    x1 = cos * x2 - sin * y2
    y1 = cos * y2 + sin * x2
    ball.vx = cos * vx1 - sin * vy1
    ball.vy = cos * vy1 + sin * vx1
    ball.x = line.x + x1
    ball.y = line.y + y1
    
    ball.draw(context)
    line.draw(context)
}())

你可能注意到这两行代码(加减号与公式相反):

// 旋转坐标
x2 = x1 * cos + y1 * sin
y2 = y1 * cos - x1 * sin

要让直线水平,需要旋转 -10°,但最后需要整个系统旋转归位,还需要计算原始角度(10°)的正余弦值。为了减少计算量,只需简单调换一下加减号即可。注:因为一四(或二三)象限 cos 一致,sin 取反。

不必旋转 line 实例,因为它只是用来让你看到反弹面,而且它还是保存斜面角度和位置的好地方。

接下来可以使用位置 x2、y2 和速度 vx1、vy1 来执行反弹。因为 y2 是相对于 line 实例的位置,所以“底边”就是 line 自己,也就是 0。考虑到小球的大小,需要判断 y2 是否大于 0 - ball.radius,即:

if (y2 > -ball.radius) {
    // do bounce
}

最后把所有东西都旋转归位,用最初的公式计算 x1、y1、ball.vx 和 ball.vy,并把 x1、y1 和 line.x、line.y 相加得到 ball 实例的绝对位置。

优化代码

其实有很多代码并不需要在每帧中都执行。许多代码仅在小球碰到直线后才需要执行。其余大多数时间,只需要执行基本的运动代码以及小球与直线的碰撞检测。这样就节省了很多计算量。随着动画越来越复杂,类似这样的优化就显得愈发重要。

优化后的代码:

(function () {
    window.requestAnimationFrame(drawFrame, canvas)
    context.clearRect(0, 0, canvas.width, canvas.height)
    
    // 常规的运动代码
    ball.vy += gravity
    ball.x += ball.vx
    ball.y += ball.vy
    
    // 以 line 为参考,获取 ball 的位置
    let x1 = ball.x - line.x
    let y1 = ball.y - line.y
    
    // 旋转坐标
    let y2 = y1 * cos - x1 * sin
    
    // 执行旋转后的反弹
    if (y2 > -ball.radius) {
        // 旋转坐标
        let x2 = x1 * cos + y1 * sin
        
        // 旋转速度
        let vx1 = ball.vx * cos + ball.vy * sin
        let vy1 = ball.vy * cos - ball.vx * sin
        
        y2 = -ball.radius
        vy1 *= bounce
        
        // 恢复旋转前
        x1 = x2 * cos - y2 * sin
        y1 = y2 * cos + x2 * sin
        ball.vx = vx1 * cos - vy1 * sin
        ball.vy = vy1 * cos + vx1 * sin
        ball.x = line.x + x1
        ball.y = line.y + y1
    }
    
    ball.draw(context)
    line.draw(context)
})()

修复“不从边缘落下”的问题

你可能注意到,即使小球到了直线的边缘,它还是会沿着直线方向滚动。这看起来很奇怪,但是别忘了小球并不是真的与 line 对象交互,运动都是通过数学算出来的。因此,需要通过碰撞检测或边界框检测来让小球知道线的位置。

修复“线下”问题

在检测碰撞时,首先要判断小球是否在直线附近,然后进行坐标旋转,得到旋转后的位置和速度。接着,判断小球旋转后的纵坐标 y2 是否越过了直线,如果是,则执行反弹。

但是如果小球位于直线下方该怎么办呢?比如碰撞检测和边界框检测都返回 true,程序会认为小球在直线上反弹,它就会把小球从直线下移到直线上。

有一个解决方案是比较 vy1 和 y2,仅当 vy1 大于 y2 的时候才执行反弹。如下图所示。

正在穿过直线还是已经到了线下

左边小球在 y 轴上的速度大于它与直线的相对距离,这意味着,它刚刚从直线上穿越下来。右边小球的速度向量小于它和直线的相对距离,也就是说,它在这一帧和上一帧中都位于线下,因此它只是在线下运动。

笔者注:可通过距离公式 y = y0 + v 推导理解。

// 需要把 y2 < vy1 加入到 if 语句中:
if (y2 > -ball.radius && y2 < vy1) {

}

本章重要公式

  • 坐标旋转
x1 = x * Math.cos(rotation) - y * Math.sin(rotation)
y1 = y * Math.cos(rotation) + x * Math.sin(rotation)
  • 反向坐标旋转
x1 = x * Math.cos(rotation) + y * Math.sin(rotation)
y1 = y * Math.cos(rotation) - x * Math.sin(rotation)

第 11 章 撞球物理

两个物体碰撞后动量如何变化,动量守恒定理,以及如何将动量守恒应用在动画中。

什么是质量?

在地球上,我们通常认为质量就代表物体有多重。它们的确关系紧密,因为重量与质量成正比。实际上,我们用同样的单位来测量质量和重量。但从严格意义上说,质量是指物体保持运动速度的能力。因此物体的质量越大,就越难以改变物体的运动状态(加减速或方向)。而重量是指带有一定质量的物体在引力场中所受的力。

质量、加速度和外力的关系:

F = m x a

动量

动量是物体质量与速度的乘积。

p = m x v

因为速度是向量,所以动量也是向量,其方向与速度向量的方向相同。

动量守恒

动量守恒是制作真实的碰撞效果的基本原理。

使用动量守恒定理,可以确定两个物体碰撞后如何反应。因此你可以说:“碰撞前,一个物体以速度 A 运动,另一个物体以速度 B 运动;碰撞后,一个物体速度变成了 C,另一个物体的速度变成了 D”。进一步分解来看,因为速度由大小的方向组成,如果已知两个物体碰撞前的速度大小和运动方向,就能计算出碰撞后的速度大小和运动方向。

当然前提是已知每个物体的质量。

动量守恒定理是一个基本的物理概念:系统在碰撞前的总动量等于系统在碰撞后的总动量。

(m0 x v0) + (m1 x v1) = (m0 x v0Final) + (m1 x v1Final)

解一个有两个未知数方程的方法,就是找出另一个含有相同两个未知数的方程。物理学中恰好有这么一个方程——动能。

KE = 0.5m x v²

动能不是向量,所有 v 仅表示速度的大小,与方向无关。

碰撞前后动能相同:
KE0 + KE1 = KE0Final + KE1Final

根据“代入消元法”可以得到两个未知数的公式:

v0Final
v1Finall

单轴上的动量守恒

if (Math.abs(dist) < ball0.radius + ball1.radius) {     
    const vx0Final = ((ball0.mass - ball1.mass) * ball0.vx + 2 * ball1.mass * ball1.vx) / (ball0.mass + ball1.mass),
    const vx1Final = ((ball1.mass - ball0.mass) * ball1.vx + 2 * ball0.mass * ball0.vx) / (ball0.mass + ball1.mass);
    ball0.vx = vx0Final;
    ball1.vx = vx1Final;
    ball0.x += ball0.vx;
    ball1.y += ball1.vx;
}

调整物体位置

避免出现两个物体嵌在一起,可以把一个物体调整到另一个小球的边缘上。但无论移动哪个都会看起来跳帧,在速度较慢时尤其明显。

方法很多,这里有一种比较简单的方法:把新速度加在物体的位置上,再次让它们弹开。如上面代码块的最后两行所示。

优化代码

以上代码出现两次几乎同样的方程,所以我们需要消除一个。

首先需要得到两个物体的相对速度,就是它们的叠加总速度。然后,当你计算出一个物体的最终速度后,再根据前面得到的相对速度,就能计算出另一个物体的最终速度。

两个物体的速度相减就能得到相对速度(注:带方向)。

在碰撞前,用 ball0.vx 减去 ball1.vx 来计算出总速度:

const vxTotal = ball0.vx - ball1.vx

然后在计算出 vx0Final 后,把它与 vxTotal 相加等到 vx1Final。

vx1Final = vxTotal + vx0Final

简化后的代码:

if (Math.abs(dist) < ball0.radius + ball1.radius) {
    const vxTotal = ball0.vx - ball1.vx;
    const ball0.vx = ((ball0.mass - ball1.mass) * ball0.vx + 2 * ball1.mass * ball1.vx) / (ball0.mass + ball1.mass);
    ball1.vx = vxTotal + ball0.vx
    ball0.x += ball0.vx;
    ball1.y += ball1.vx;
}

注:当两个相同质量的物体碰撞时,有更简单的做法:沿着碰撞的方向,物体简单地交换它们的速度。尽管仍然使用坐标旋转来确定碰撞角度与物体在这个角度上的速度,不过省去了复杂的动量守恒。

假设两物体质量相同,那么上述代码将可以改为:

// 旋转 ball0 的速度
vel0 = rotate(ball0.vx, ball0.vy, sin, cos)

// 旋转 ball1 的速度
vel1 = rotate(ball1.vx, ball1.vy, sin, cos)

const temp = vel0
vel0 = vel1
vel1 = temp

双轴上的动量守恒

二维空间中的碰撞,因为速度方向不在 x 轴上,所以不能直接把速度带入动量守恒公式。因此需要将整个场景旋转至与一维空间一样(位置和速度)。与第 10 章斜面反弹的做法完全一样。

将二维旋转至一维

两球间的角度很重要,这是碰撞角度。你只需关心小球的位于碰撞角度上的速度分量——vx。

只需关系 x 轴上的速度

这就和单轴的情况一样,使用公式得到两个新的 vx 值,而 vy 的值永远不变,即 vx 的变化单独影响了总体速度。

最后把所有东西后旋转回原位后,就得到了每个球最终真实的 vx 和 vy。

旋转归位

多球时的潜在问题

如果屏幕上有三个小球——ball0、ball1 和 ball2,它们恰好离得很近。下面是要发生的事情:

  • 程序依照三个小球的速度移动它们。
  • 程序检测 ball0 和 ball1,ball0 和 ball2,发现它们并没有碰撞。
  • 程序检测 ball1 和 ball2,因为它们发生了碰撞,所以它们的速度和位置都要重新计算,然后弹开。这恰好不小心让 ball1 和 ball0 接触上了。然而,这一组已经进行过检测了,所以就忽略它。
  • 在下一轮循环中,程序依然按照它们的速度移动小球。这样有可能使得 ball0 和 ball1 更为靠近。
  • 现在程序发现 ball0 和 ball1 碰撞了。它会重新计算两个小球的速度和位置,将它们分开。但是,因为它们已经发生了接触,所以这可能并不能真正地分开它们,它们就卡在了一起。

注意,这种情况最容易发生在空间小、物体多并且移动速度高的情况下。这也会发生在物体一开始就接触的情况下。

问题出现在以下两行代码:

// 更新位置
pos0.x += vel0.x
pos1.x += vel1.x

这个假设碰撞只是由两个小球自己的速度引起的,然后把它们新的速度加回去以分开它们。大多数情况下,这是对的。但是在我们刚才说的那个场景例外。所以需要在移动之前更加严谨地确保两个物体是分离的。

// 更新位置
const absV = Math.abs(vel0.x) + Math.abs(vel1.x)
const overlap = (ball0.radius + ball.radius) - Math.abs(pos0.x - pos1.x)
pos0.x += vel0.x / absV * overlap
pos1.x += vel1.x / absV * overlap

这不是数学中最精确的方法,但看起来工作得很好,其思路是:

  • 首先确定绝对速度(两个物体速度的绝对值之和)
  • 确定两个小球的重叠量,这通过总半径减去距离得到
  • 根据每个小球速度与绝对速度的比例,把它们移开重叠量的一部分距离
  • 最后两个小球刚刚接触,但没有重叠

第 12 章 粒子与万有引力

第 5 章介绍的过万有引力(重力),但那是从微观角度来看的重力。站在地球上,重力的描述很简单:它把物体向下拉。实际上,它以特定的速率把物体向下拉。

当你往后退时,离一个星球或者很大的物体越远,受到的引力就越小。这对于地球和其他行星来说是个很好的现象,避免了被吸进太阳里捣得粉碎。从遥远的、宏观的视角看太阳系,你可以把行星看作例子,它们间的距离会影响万有引力。

距离与万有引力的关系很容易描述:万有引力与距离的平方成反比。另外,引力与质量关系紧密,一个物体的质量越大,它对其他物体的引力就越大,它受到其他物体的引力同样也越大(注:力是相互的,另外,根据 F=ma,质量小的能获得更大的加速度)。

force = G x m1 x m2 / distance²

G 是万有引力常数,等于 6.674 x 10^-11 x m³ x kg^-1 x S^-2

若需要对宇宙建模,用牛顿作为计量单位,就需要带上 G。而对于动画,将其设置为 1 省去。

计算两粒子的引力作用:

function gravitate (partA, partB) {
    const dx = partB.x - partA.x
    const dy = partB.y - partA.y
    const distSQ = dx * dx + dy * dy
    const dist = Math.sqrt(distSQ)
    const force = partA.mass * partB.mass / distSQ
    const forceX = force * dx / dist // cos
    const forceY = force * dy / dist // sin
    
    // 加减取决于计算 dx 和 dy 的相减顺序
    partA.vx += forceX / partA.mass
    partA.vy += forceY / partA.mass
    partB.vx -= forceX / partB.mass
    partB.vy -= forceY / partB.mass
}

这些粒子一开始静止,然后相互吸引。偶尔两个粒子会相互绕圈,但是大多数情况下,这些粒子相互接近,然后向相反方向飞出。

这样碰撞后高速飞出是代码中的 bug 吗?并不是,这正是期望的效果。这个行为叫做弹弓效应(slingshot effect),NASA 就是使用这种效应把探测器发射到外太空去。随着一个物体与一个星球越来越近,它的加速度越来越大,速度会越来越高。如果你瞄得恰到好处,物体就会掠过星球,以足够大的速度摆脱星球的引力,进入太空。

在程序中,当两个物体距离很小的时候——几乎是零距离,它们之间的引力变得非常大,几乎是无限大。这样在数学上是对的。不过,从模拟的角度看,这并不真实。应该发生的结果是:在两个物体足够接近时,我们来控制碰撞。如果你把空间探测器瞄准一个星球,它就不能以无限大的速度接近,这样只能撞出一个火山口。

碰撞检测及反应

碰撞后的反应,可以是爆炸或者消失,或者可以让一个粒子消失,然后把它的质量加在另一个上,就像是两个融合了。

碰撞弹开则用到上一章节——撞球物体的知识。

其实这也许是 https://tendril.ca/ 网站,多个小人追随目标地的实现原理。

轨道运动

为了演示轨道运动,我们建立一个简单的行星系,其中有一个太阳和一个行星。设置太阳的质量为 10000,行星的质量为 1。让行星距离太阳一段距离,然后给它一个沿太阳切线方向的初速度。

如果设置了合适的质量、距离和速度,你就能让行星进入轨道(经过一些试验)。

万有引力 VS 弹力

如果你观察万有引力和弹力,就会发现它们相似但几乎完全相反。它们都是在两个物体上施加加速度使它们接近。但是对于万有引力,两个物体距离越大,加速度越小;对于弹力,两个物体距离越大,加速度越大。

可以用弹力来替换水上一个例子的万有引力的代码,但结果并不有趣。例子最终会黏成一团——弹力不能容忍距离。

这是一个两难的处境,你既想让粒子间通过弹力相互吸引,又想让它们保持一定距离,不黏在一起。我们可以设置一个最小距离来解决这个问题,如果两个粒子间的距离大于这个最小距离,则忽略对方。

function spring (partA, partB) {
    const dx = partB.x - partA.x
    const dy = partB.y - partA.y
    const dist = Math.sqrt(dx * dx, dy * dy)
    
    if (dist < minDist) {
        const ax = dx * springAmount
        const ay = dy * springAmount
        
        partA.vx += ax
        partA.vy += ay
        partB.vx -= ax
        partB.vy -= ay
    }
}

这样粒子都聚成一团,就像一群苍蝇围着垃圾堆嗡嗡叫。这些团也会移动,散开,与其他团结合,这是一个有趣的自然行为。

第 13 章 正向运动学:让物体行走

运动学:是一个数学分支,用来处理物体的运动,但不关心质量和外力,因此它关心速度、方向。

当计算机科学、图形学、游戏领域的人说起运动学时,他们通常涉及运动学的两个特殊分支:正向运动学和反向运动学。

介绍正向和反向运动学

正向和反向运动学通常与多个部件组合而成的系统相关,比如,一个链条或者一个由关节组成的手臂。它们来解决整个系统如何运动,以及每个部件相对于其他部件和整个系统如何运动。

通常,一个运动学系统有两个端点:基础端和自由端。由关节组成的手臂通常一端固定,另一端可以随意伸出去拿一个东西。链条可能有一端或者两端固定,或者都不固定。

正向运动学(Forward Kinematics, FK)的动作起源于固定端,移动自由端。
反向运动学(Inverse Kinematics, FK)的动作开始于,或者决定于自由端,移动向固定端(若有)。

用例子区分它们的区别。

大多数情况下,在行走时,四肢的运动是正向运动学。大腿带动小腿,小腿带动脚。脚不决定其他任何东西,它的运动取决于大腿和小腿的运动。

反向运动学的例子是去拉一个人的手,这时力量施加在自由端(那个人的手),移动手的位置就能移动小臂、最终影响到整个身体。

拖拽和伸手去拿一般是反向运动学,但是一个重复周期的运动,如行走,往往是正向运动学。

正向运动学编程入门

编写两种运动学的程序都包含以下一些基本元素:

  • 系统的部件——节段(segment)
  • 每个节段的位置
  • 每个节段的旋转

每个节段都有一端是轴心点,它可以围绕轴心点旋转。如果一个节段的一端有子阶段,那么它的轴心点在另一端。比如,上臂的轴心点在肩膀,前臂的轴心点在肩膀,前臂的轴心点在肘部,手的轴心点在腕部。

当然,在很多真实的系统中,节段可能在多个方向上围绕轴心点旋转。如转动手腕。

移动一个节段

function Segment(width, height, color) {
    this.x = 0
    this.y = 0
    this.width = width
    this.height = height
    this.vx = 0
    this.vy = 0
    this.rotation = 0
    this.scaleX = 1
    this.scaleY = 1
    this.color = (color === undefined) ? "#ffffff" : utils.parseColor(color)
    this.lineWidth = 1
}

Segment.prototype.draw = function (context) {
    const h = this.height
    const d = this.width + h // 包含两端半圆的半径
    const cr = h / 2         // 圆角半径
    context.save()
    context.translate(this.x, this.y)
    context.rotate(this.rotation)
    context.scale(this.scaleX, this.scaleY)
    context.lineWidth = this.lineWidth
    context.fillStyle = this.color
    context.beginPath()
    context.moveTo(0, -cr)
    context.lineTo(d - 2 * cr, -cr)
    context.quadraticCurveTo(-cr + d, -cr, -cr + d, 0)
    context.lineTo(-cr + d, h - 2 * cr)
    context.quadraticCurveTo(-cr + d, -cr + h, d - 2 * cr, -cr + h)
    context.lineTo(0, -cr + h)
    context.quadraticCurveTo(-cr, -cr + h, -cr, h - 2 * cr)
    context.lineTo(-cr, 0)
    context.quadraticCurveTo(-cr, -cr, 0, -cr)
    context.closePath()
    context.fill()
    if (this.lineWidth > 0) {
        context.stroke()
    }
    // 绘制两个插销点
    context.beginPath()
    context.arc(0, 0, 2, 0, (Math.PI * 2), true)
    context.closePath()
    context.stroke()

    context.beginPath()
    context.arc(this.width, 0, 2, 0, (Math.PI * 2), true)
    context.closePath()
    context.stroke()

    context.restore()
}

// 右边插销点的位置
Segment.prototype.getPin = function () {
    return {
        x: this.x + Math.cos(this.rotation) * this.width,
        y: this.y + Math.sin(this.rotation) * this.width
    }
}

本章只记录一小部分,更多内容请阅读书本。

第 14 章 反向运动学:拖拽与伸出

伸出和拖拽单个节段

伸出:当系统的自由端伸向一个目标时,系统的另一端(基础端)可能是固定的。所以,如果目标超出范围,自由端有可能永远够不到它。反向运动学会告诉你如何调整位置以实现最佳的伸出效果。

拖拽:自由端被外力拖动。无论它被拖到哪里,系统的其他部分都跟随其后,位置由物理原理决定。反向运动学告诉你如何当各个部件被拖动时位置如何变化。

伸出单个节段

对于伸出而言,所有节段都要向目标旋转。

const dx = mouse.x - segment0.x
const dy = mouse.y - segment0.y

segment0.rotation = Math.atan2(dy, dx)
segment0.draw(context)

拖拽单个节段

拖拽方法的开始部分与伸出方法相同:向鼠标指针方向旋转节段。其次要把节段的第二个轴心点移动到鼠标位置。这需要知道两个轴心点在 x、y 轴上的距离——可以叫做 w 和 h,它们可以通过节段的位置以及 getPin() 方法的返回值计算得到。然后从当前鼠标指针位置中再减去 w 和 h,这就是节段要移动到的位置。

const dx = mouse.x - segment0.x
const dy = mouse.y - segment0.y

segment0.rotation = Math.atan2(dy, dx)

// S 相对上列新增部分
const w = segment0.getPin().x - segment0.x
const h = segment0.getPin().y - segment0.y

segment0.x = mouse.x - w
segment0.y = mouse.y - h
// E 相对上列新增部分

segment0.draw(context)

本章只记录一小部分,更多内容请阅读书本。

另外,网易的 《睡姿大比拼》 应该就是基于反向运动学的伸出实现。

第四部分 3D 动画

第 15 章 三维基础

三维背后的主要概念就是存在一个除了 x、y 轴之外的维度。这个维度表示深度,它通常叫做 z。

接下里的案例均使用右手坐标系统。

两种坐标轴系统

透视图

有很多技术可以用来表示透视图,但是我们只关心两种:

  • 物体变小代表它远离
  • 远离的物体会汇聚在一个消失点上

所以,当在 z 轴上移动物体时,要做两件事:

  • 放大或缩小物体
  • 让它靠近或远离消失点

在二维系统中,可以使用屏幕的 x、y 坐标作为物体的 x、y 坐标,因为它们是一一对应的。但是这在三维系统中行不通,因为两个物体可能拥有相同的 x、y 坐标,但是由于它们的深度不同,它们在屏幕上的位置就不一样。在三维系统中的任何一个物体都有自己的 x、y 和 z 坐标,这个坐标描述在虚拟空间内的位置。透视图的计算告诉我们应该把物体放在屏幕上的哪个位置。注:还有正视图等。

透视图

透视图公式

基本思想:随着物体的远离(z 坐标增加),它的大小缩小到 0,同时 x、y 坐标向消失点移动。因为缩放的比例和接近消失点的比例相同,所以只需根据距离计算出缩放比例,然后两个地方都能使用这个比例进行计算。

在这里,有一个正在远离你的物体,一个观察点(相机)和一个成像面(即屏幕)。物体和成像面之间有一段距离,z 值。观察点到成像面也有一段距离,这与照相机镜头的焦距相似,所以用变量 f1 表示。长焦距可以比作长焦镜头,它可以拉近远处的物体,但视野比较小。短焦距就像是广角镜头,视野很大,但是有些变形。中等的焦距类似人类的眼睛,f1 为 200~300 之间的值。透视图公式:

scale = f1 / (f1 + z)

公式通常会产生 0.0~1.0 之间的值,这就是用来缩放和靠近消失点的比例。当 z 小于或等于 -f1 时,我们可以让物体消失,避免出现 scale 为负数,当把它应用到 canvas 的上下文中,会导致图像坐标系翻转,从而看到小球变大然后变小。

if (z > -f1) {
    const scale = f1 / (f1 + z)
    x = mouse.x - vpX // vpX 为消失点 x,值为 canvas.width / 2
    y = mouse.y - vpY
    ball.scaleX = ball.scaleY = scale
    ball.x = vpX + xpos * scale
    ball.y = vpY + ypos * scale
    ball.visible = true
} else {
    ball.visible = false
}
if (ball.visible) {
    ball.draw(context)
}

Z 排序

Z 排序是指物体在 z 轴上如何排序,或者表示物体哪个在前,哪个在后。

function zSort (a, b) {
    return (b.z - a.z)
}
balls.sort(zSort)

这个排序基于每个元素的 z 属性,依照数字的反序排列,换句话说,就是从高到低。得到的结果是,最远的物体(z 值最大)处于数组中首位,因为第一个被绘制在 canvas 上。最近的小球是数组中的最后一个元素,它被绘制在所有其他小球之上。

三维系统能对之前章节的各种动画效果进行延伸,如重力反弹、屏幕环绕等等。

重力反弹

Bouncy Balls and Gravity

屏幕环绕(如三维赛车类游戏)

Running Through a Forest with Screen-Wrapping

坐标旋转

在二维坐标旋转中,坐标点是绕着 z 轴旋转。只有 x 坐标和 y 坐标在改变。

在三维系统中,也可以绕 x 轴或 y 轴旋转。点绕着 x 轴旋转并且只改变其 y 坐标和 z 坐标。
绕 y 轴旋转,改变其 x 坐标和 z 坐标。

因此,在三维系统中,当物体绕着某一条轴旋转时,它在其他两条轴上的坐标发生变化。与第十章中的二维坐标旋转基本相同,但是要指定绕哪条轴旋转:x、y 或 z。这样就得到以下三组公式:

x1 = x * cos(angleZ) - y * sin(angleZ)
y1 = y * cos(angleZ) + x * sin(angleZ)

x1 = x * cos(angleY) - z * sin(angleY)
z1 = z * cos(angleY) + x * sin(angleY)

y1 = y * cos(angleX) - z * sin(angleX)
z1 = z * cos(angleX) + y * sin(angleX)

第 16 章 三维线条与填充

上一章节只是通过计算物体的大小和屏幕位置把它们至于三维空间中,但物体本身仍然是二维的。

function Point3d(x, y, z) {
    // x、y、z 是实际位置,会导致物体围绕着三维空间的中心旋转
    this.x = (x === undefined) ? 0 : x
    this.y = (y === undefined) ? 0 : y
    this.z = (z === undefined) ? 0 : z
    this.fl = 250 // 焦距
    this.vpX = 0 // 消失点
    this.vpY = 0
    this.cX = 0 // 中心点,移动整个模型,同时绕着自己的中心旋转
    this.cY = 0
    this.cZ = 0
}

Point3d.prototype.setVanishingPoint = function (vpX, vpY) {
    this.vpX = vpX
    this.vpY = vpY
}

Point3d.prototype.setCenter = function (cX, cY, cZ) {
    this.cX = cX
    this.cY = cY
    this.cZ = cZ
}

Point3d.prototype.rotateX = function (angleX) {
    const cosX = Math.cos(angleX)
    const sinX = Math.sin(angleX)
    const y1 = this.y * cosX - this.z * sinX
    const z1 = this.z * cosX + this.y * sinX
    this.y = y1
    this.z = z1
}

Point3d.prototype.rotateY = function (angleY) {
    const cosY = Math.cos(angleY)
    const sinY = Math.sin(angleY)
    const x1 = this.x * cosY - this.z * sinY
    const z1 = this.z * cosY + this.x * sinY
    this.x = x1
    this.z = z1
}

Point3d.prototype.rotateZ = function (angleZ) {
    const cosZ = Math.cos(angleZ)
    const sinZ = Math.sin(angleZ)
    const x1 = this.x * cosZ - this.y * sinZ
    const y1 = this.y * cosZ + this.x * sinZ
    this.x = x1
    this.y = y1
}

Point3d.prototype.getScreenX = function () {
    const scale = this.fl / (this.fl + this.z + this.cZ)
    return this.vpX + (this.cX + this.x) * scale
}

Point3d.prototype.getScreenY = function () {
    const scale = this.fl / (this.fl + this.z + this.cZ)
    return this.vpY + (this.cY + this.y) * scale
}

与其他三维系统类似,模型都有点、线、三角形组成。

而三角形的顶点都是按照顺时针方向排序,其法向量是面的指向方向,用于背面剔除(backface culling)。

第 17 章 背面剔除与三维灯光

多边形顶点的顺逆时针

我们使用屏幕坐标来判断一个多边形的顶点是顺时针还是逆时针方向。不是三维 x、y、z 坐标,而是调用 getScreenX()、getScreen() 得到的经过透视计算的 canvas 坐标。

Triangle.prototype.isBackface = function () {
    const cax = this.pointC.getScreenX() - this.pointA.getScreenX()
    const cay = this.pointC.getScreenY() - this.pointA.getScreenY()
    const bcx = this.pointB.getScreenX() - this.pointC.getScreenX()
    const bcy = this.pointB.getScreenY() - this.pointC.getScreenY()
    return cax * bcy > cay * bcx
}

我们将 A,B 和 A,C 做一条向量,这两条向量为:U = B - A,V = C - A
因为都在 X,Y 平面,所以有:U = (Ux, Uy, 0), V = (Vx, Vy, 0)
然后使用叉积计算 U 和 V:U × V = (0, 0, UxVy - UyVx)
然后通过判断 UxVy - UyVx 的符号来判断三角形的朝向。
负值:用左手判断 3 个顶点的方向是顺时针方向;
正值:为逆时针方向。
来自:三角形正面判断

增强的深度排序

深度排序,或者 z 排序,已经在第 15 章中介绍透视图时讨论过了。在那个例子中,依据一个数组中三维物体的 z 属性对它们进行排序。

但是现在,你并不是在处理多个物体。所以需要对组成模型的三角形数组进行排序。这里依据的是三角形的深度,这个值是组成三角形的三个顶点的 z 坐标的最小值。

Triangle.prototype.getDepth = function () {
    return Math.min(this.pointA.z, this.pointB.z, this.pointC.z)
}

然后对三角形对象数组进行排序,确定三角形绘制的先后顺序。要按升序排列,要让最远的那个排在第一位。

function depth (a, b) {
    return (b.getDepth() - a.getDepth())
}

第五部分 其他技巧

第 18 章 矩阵数学

矩阵被大量应用于 3D 系统中,以实现旋转、缩放以及平移 3D 坐标的功能。它也常用语各种 2D 图形的变换。

矩阵的下标都是从 1 开始计数,如下图中,M2,3(下标)是 6。

矩阵运算

矩阵加法

矩阵的一个常见用途是操作 3D 空间中的点,这样一个点分别包含 x、y 与 z 轴上的坐标。可以将其简单地视为一个 1 x 3 的矩阵。

x y z

为了实现点在空间中的移动,也称为点的平移,需要知道它在每条轴上的移动距离。可以将每条轴上的移动距离填充到一个平移矩阵中,就像下面这个 1 x 3 的矩阵:

dx dy dz

这里,dx、dy 与 dz 分别为 x、y 与 z 轴上的移动距离。现在要通过矩阵加法将平移矩阵作作用于 3D 点上。只需将每个对应的单元格的数值加在一起就可以创造出一个包含每个单元格之和的新矩阵。只有两个同样大小的矩阵才能相加。点的平移如下所示:

x y z + dx dy dz = (x + dx) (y + dy) (z + dz)

矩阵乘法

矩阵乘法是 3D 转化的计算中更为常用的一种方法,它通常用于缩放和旋转。

使用矩阵进行缩放

首先,需要知道一个物体现有的宽度、高度与深度,换句话说,也就是它在三条轴上每个分量的大小。

w h d

然后要用到像下面这样的一个缩放矩阵:

sx 0  0
0  sy 0
0  0  sz

在这个矩阵中,sx、sy 与 sz 分别为对应轴上的缩放比例。它们都是以分数或小数的形式出现,1.0 表示 100%,0.5 则表示 50% 等。

        sx 0  0
w h d * 0  sy 0
        0  0  sz

计算结果如下:

(w * sx) (h * sy) (d * sz)

矩阵乘法有一个必要条件,第一个矩阵的列数必须等同于第二个矩阵的行数,只要符合这个标准,无论第一个矩阵有多少行,第二个矩阵有多少列,它们都可以相乘,否则它们无法进行乘法运算。

        a b c
u v w * d e f
        g h i

等于 (u * a + v * d + w * g) (u * b + v * e + w * h) (u * c + v * f + w * i)

矩阵相乘后得到的新矩阵的大小的行列数分别由第一个矩阵的行数和第二个矩阵的列数决定。

使用矩阵进行坐标旋转

首先,我们将再次用到 3D 空间中一个点的矩阵:

x y z

它持有待旋转点的三维坐标。现在,我们需要一个旋转矩阵,通过它我们可以在三条轴中任意一条轴上进行旋转。我们将分别为每种类型的旋转创建一个矩阵。先从 x 轴的旋转矩阵开始:

1    0    0
0    cos  sin
0    -sin cos

计算得出:
(x * 1 + y * 0 + z * 0) (x * 0 + y * cos - z * sin) (x * 0 + y * sin + z * cos)

整理后结果如下:

(x) (y * cos - z * sin) (z * cos + y * sin)

与之对应的 JavaScript 代码如下:

x = x
y = y * Math.cos(rotation) - z * Math.sin(rotation)
z = z * Math.cos(rotation) + y * Math.sin(rotation)

这里的方法与之前介绍的围绕 x 轴的旋转方法完全一致。这并没有什么值得惊奇的,因为矩阵数据仅仅是组织各种公式与方程的另一种方法而已。

围绕 y 轴旋转的矩阵:

cos  0  sin
0    1  0
-sin 0  cos

围绕 z 轴旋转的矩阵:

cos  sin  0
-sin cos  0
0    0    1

canvas 变换

矩阵的另一个重要功能是用于操纵 canvas 上显示的图形。通过应用一个变换矩阵,可以实现图形的旋转、缩放以及平移,进而改变他们的形状、大小与位置。

canvas 上下文在内容使用像下面这样一个 3x3 的变换矩阵:

a c dx
b d dy
u v w

该变换也称为仿射变换,这意味着,为了能应用仿射变换,二维向量 (x, y) 需要改写为三维向量 (x, y, 1)。由于 (u, v, w) 并不会用到,他们会直接设为 (0, 0, 1),并保持不变。所以你不用管它们。

可以通过调用以下函数设置 canvas 上下文的变换矩阵:

context.setTransform(a, b, c, d, dx, dy)

要将当前的 canvas 上下文中的变换矩阵再乘上一个新的变换矩阵(注:即累计),可以调用以下函数:

context.transform(a, b, c, d, dx, dy)

如果没有为 canvas 设置任何变换矩阵,那么 canvas 会认为我们使用了一个单位矩阵(identity matrix)或一个空矩阵,就是类似下面这样一个矩阵:

1 0 0
0 1 0
0 0 1

为 canvas 上下文应用该矩阵不会产生任何变换。所以,每当你希望重置 canvas 上下文时,可以将它的变换矩阵设置为单位矩阵,如下所示:

context.setTransform(1, 0, 0, 1, 0, 0)

变化矩阵的那些字母元素的含义:

dx 和 dy 控制 canvas 上下文将要在 x 与 y 轴上平移的距离。注意,坐标 (0, 0) 位于 canvas 的左上角。

而 a、b、c、d 则有点复杂,它们之间的联系非常紧密。如果将 b 与 c 设为 0,则可以借助 a 与 d 实现物体在 x 轴与 y 轴上的缩放。而如果将 a 与 d 设为 1,则可以通过 b 与 c 让物体在 y 轴 与 x 轴上倾斜。甚至可以将 a、b、c、d 联合起来设置成下面这个我们熟悉的矩阵:

cos  -sin  dx
sin  cos   dy
u    v     w

这里包含一个旋转矩阵。很容易想到,这里的 cos 与 sin 代表 canvas 上下文将要旋转的角度(以弧度为单位)的余弦和正弦值。

倾斜,倾斜是将物体沿着某条轴拉伸使得物体的两端沿着两个相反的方向运动。这种变换想要通过某个公式实现是非常复杂的,而借助变换矩阵就变得很容易。

将矩阵中的 a 与 d 设为 1,剩下的 b 可用于指定物体在 y 轴上的倾斜程度,而 c 则用于指定物体在 x 轴上的倾斜程度。

注:正值是往左/上(以左上角为参考点)

切斜效果经常用于实现伪 3D。

在计算机图形学的各种应用中都能找到矩阵的身影。它广泛应用于计算机视觉过滤器、图像处理(比如边缘检测)、锐化以及模糊变换。随着你不断地深入到更加高级的计算图形编程中,你会发现更多有关矩阵的应用。

第 19 章 秘诀与技巧

布朗(随机)运动

布朗运动:虽然水看起来是静止的,但是一滴水中有无数水分子,它们在不断地运动。一些分子与花粉或灰尘发生碰撞,这样就会把动量传递给它们。

模拟效果:在每一帧中,计算随机数并累加给移动物体的 x、y 速度上,随机数有正有负,并且非常小,比如在 -0.1~0.1 之间。

function draw (dot) {
    dot.vx += Math.random() * 0.2 - 0.1
    dot.vy += Math.random() * 0.2 - 0.1
    dot.x += dot.vx
    dot.y += dot.vy
    dot.vx *= friction // 摩擦力,避免速度过渡累积,产生不自然的效果
    dot.vy *= friction
}

运动轨迹的生成

rgba(255, 255, 255, 0.01) 绘制一个矩形来替换 context.clearRect。这样每一帧中都不会擦除粒子的运动轨迹,只会逐步地让图形越来越淡。

笔者认为绘制 100 次后会完全变白,但实际并不会。相关讨论:rgba fillStyle with alpha does not get fully opaque if applied multiple times

随机分布

通过计算随机数的平方根(偏向 1,远离 0),可以让分布更加平滑。

while (numDots--) {
    const radius = Math.sqrt(Math.random()) * maxRadius
    const angle = Math.random() * (Math.PI * 2)
    const x = canvas.width / 2 + Math.cos(angle) * radius
    const y = canvas.height / 2 + Math.sin(angle) * radius
}

通过平方根让分布显得更随机

偏向分布

让小圆点随机分布在整个 canvas 上,但让它们趋向分布在中心区域。即有一些在边缘附近,但越接近中心,分布得越多。这与第一个圆形分布的例子相似,但这次是在矩形区域内。

通过给为每一个位置产生多个随机数并计算它们的平均值来实现。

while (numDots--) {
    for (let i = 0, xpos = 0; i < iterations; i++) {
        xpos += Math.random() * canvas.width
    }
    const x = xpos / iterations
}

1 次遍历的偏向分布

6 次遍历的偏向分布