第四章:向量绘图与递归图形
本章适合人群:有基础编程经验、对图形学感兴趣的初级开发者
学习目标:理解数学向量的本质,掌握用向量绘制几何图形的方法,学会用递归创造自然图案
目录
- 什么是向量?
- Vector2D 类:位置、方向与模长
- 用向量绘制正多边形
- 递归分形树:递归如何创造自然图案
- 海龟绘图系统:pen、forward、right
- L-系统基础:用字符串生成分形
- 图形中的随机性:让画面更自然
- 常见问题 Q&A
1. 什么是向量?
1.1 生活类比:导航中的"位移"
想象你站在一个十字路口,朋友打电话问你:"怎么去最近的地铁站?"
你会说:"往前走 500 米,然后右转,再走 300 米。"
注意——你没有说"地铁站在东经 116.4 度、北纬 39.9 度"。你给出的是相对位移:从当前位置出发,先走多远、再朝哪个方向走多远。
这个"走多远 + 朝哪个方向"的组合,就是向量(Vector)。
向量的本质:向量 = 方向 + 大小(长度)。它描述的是"从 A 点到 B 点该怎么走",而不是"B 点在哪里"。
1.2 向量的数学表示
在二维平面上,一个向量可以用两个数字表示:
v = (x, y)
x:水平方向走了多远(向右为正,向左为负)y:垂直方向走了多远(向上为正,向下为负)
比如向量 (3, 4) 表示:向右走 3 个单位,再向上走 4 个单位。
1.3 常见误区澄清
| 误区 | 真相 |
|---|---|
| "向量就是坐标点" | 向量是位移,不是位置。(3,4) 可以是从 (0,0) 到 (3,4),也可以是从 (10,10) 到 (13,14) |
| "向量必须有起点在原点" | 向量与起点无关,只关心"走了多远、朝哪走" |
"C++ 的 std::vector 就是数学向量" |
C++ 的 vector 是动态数组,和数学向量完全是两回事 |
1.4 向量的基本运算
向量加法:两个位移叠加
(3, 4) + (1, 2) = (4, 6)
就像你先走 (3,4),再从那里走 (1,2),最终相当于走了 (4,6)。
向量数乘:把位移放大或缩小
(3, 4) * 2 = (6, 8)
方向不变,长度变成两倍。
向量旋转:改变方向,保持长度
把向量 (1, 0) 逆时针旋转 90 度,变成 (0, 1)。
旋转公式(逆时针旋转 theta 弧度):
x' = x * cos(theta) - y * sin(theta)
y' = x * sin(theta) + y * cos(theta)
这个公式怎么来的?想象向量 (x, y) 的极坐标表示:长度 r,角度 phi。旋转后角度变成 phi + theta:
x = r * cos(phi) y = r * sin(phi)
x' = r * cos(phi + theta) = r * (cos(phi)cos(theta) - sin(phi)sin(theta)) = x*cos(theta) - y*sin(theta)
y' = r * sin(phi + theta) = r * (sin(phi)cos(theta) + cos(phi)sin(theta)) = x*sin(theta) + y*cos(theta)
1.5 动手试一试
练习 1.1:向量 (3, 4) 旋转 90 度后是什么?
点击查看答案
cos(90°) = 0, sin(90°) = 1
x' = 3 * 0 - 4 * 1 = -4
y' = 3 * 1 + 4 * 0 = 3
结果是 (-4, 3)。你可以画出来验证:原向量指向右上方,旋转 90 度后指向左上方。
2. Vector2D 类
2.1 为什么需要封装向量?
在图形编程中,我们频繁地进行向量运算。如果每次都手写 x*cos - y*sin,代码会又臭又长还容易出错。封装成类后,我们可以写出像 v.rotate(angle).scale(2) 这样优雅的链式调用。
2.2 Vector2D 的实现
下面是课程使用的 Vector2D 类(位于 akira-graphics/common/lib/vector2d.js):
export class Vector2D extends Array {
constructor(x = 1, y = 0) {
super(x, y);
}
// getter/setter 让访问更方便
set x(v) { this[0] = v; }
set y(v) { this[1] = v; }
get x() { return this[0]; }
get y() { return this[1]; }
// 向量的长度(模长):|v| = sqrt(x^2 + y^2)
get length() {
return Math.hypot(this.x, this.y);
}
// 向量的方向角(与 x 轴正方向的夹角)
get dir() {
return Math.atan2(this.y, this.x);
}
// 复制一个相同的向量
copy() {
return new Vector2D(this.x, this.y);
}
// 向量加法:this = this + v
add(v) {
this.x += v.x;
this.y += v.y;
return this;
}
// 向量减法
sub(v) {
this.x -= v.x;
this.y -= v.y;
return this;
}
// 数乘:长度缩放 a 倍
scale(a) {
this.x *= a;
this.y *= a;
return this;
}
// 叉积(二维叉积返回标量,表示有向面积)
cross(v) {
return this.x * v.y - v.x * this.y;
}
// 点积:判断两个向量是否同向
dot(v) {
return this.x * v.x + this.y * v.y;
}
// 归一化:变成单位向量(长度为 1,方向不变)
normalize() {
return this.scale(1 / this.length);
}
// 旋转 rad 弧度(逆时针)
rotate(rad) {
const c = Math.cos(rad);
const s = Math.sin(rad);
const [x, y] = this;
this.x = x * c + y * -s;
this.y = x * s + y * c;
return this;
}
}
2.3 核心概念详解
位置(Position)
在 Canvas 中,位置本质上就是一个从原点 (0,0) 出发的向量。我们常说"点在 (100, 200)",其实就是"从原点出发,位移 (100, 200) 到达该点"。
const pos = new Vector2D(100, 200);
// pos.x === 100, pos.y === 200
方向(Direction)
方向是一个单位向量(长度为 1 的向量),只表示"朝哪走",不表示"走多远"。
const dir = new Vector2D(1, 0); // 指向正右方
const up = new Vector2D(0, 1); // 指向正上方
通过 rotate() 可以改变方向:
const northeast = new Vector2D(1, 0).rotate(Math.PI / 4);
// 指向东北方向(45度)
模长(Magnitude / Length)
模长就是向量的长度,用勾股定理计算:
|v| = sqrt(x^2 + y^2)
const v = new Vector2D(3, 4);
console.log(v.length); // 5,因为 3^2 + 4^2 = 5^2
2.4 链式调用的魔力
注意每个方法都 return this;,这让我们可以链式调用:
// 先复制,再旋转 60 度,再缩放 100 倍
const newDir = dir.copy().rotate(Math.PI / 3).scale(100);
为什么要 copy()? 因为 rotate() 和 scale() 会修改原向量。如果我们不想改变原来的方向,就先复制一份。
2.5 常见误区
| 误区 | 真相 |
|---|---|
"v.length 可以赋值修改" |
length 是 getter,只能读取。要改长度用 scale() |
"rotate() 返回新向量" |
rotate() 修改自身并返回自身。要保留原向量先 copy() |
"归一化后向量变成 (1,1)" |
归一化后长度为 1,(3,4) 归一化后是 (0.6, 0.8) |
2.6 动手试一试
练习 2.1:用 Vector2D 实现一个函数,计算两点之间的距离。
点击查看答案
function distance(p1, p2) {
return p1.copy().sub(p2).length;
// 或者:Math.hypot(p1.x - p2.x, p1.y - p2.y)
}
思路:先算位移向量 p1 - p2,再取它的长度。
练习 2.2:创建一个指向 30 度方向、长度为 50 的向量。
点击查看答案
const v = new Vector2D(1, 0) // 先指向正右方
.rotate(Math.PI / 6) // 旋转 30 度(pi/6 弧度)
.scale(50); // 长度变为 50
3. 用向量绘制正多边形
3.1 生活类比:沿着罗盘转圈
想象你站在一片空地上,手里拿着一根绳子。你要在地上钉出一个正三角形:
- 向前走一段距离,钉下第一个桩
- 原地向左转 120 度
- 再走同样的距离,钉下第二个桩
- 再转 120 度,走同样的距离,回到起点
正多边形的绘制,本质上就是**"走一段 -> 转一个角度 -> 再走一段"**的循环。
3.2 核心原理:外角与边数的关系
对于任意正多边形,每走完一条边需要转动的外角是:
外角 = 360 deg / 边数 = 2pi / 边数(弧度)
| 边数 | 外角 |
|---|---|
| 3(三角形) | 120 deg |
| 4(正方形) | 90 deg |
| 6(六边形) | 60 deg |
| 60(近似圆) | 6 deg |
为什么?因为走完所有边后,你正好转了一圈(360 度),平均分配到每条边之间的转角。
3.3 代码实现
下面是 vector_draw/app.js 的完整代码:
import { Vector2D } from '../common/lib/vector2d.js';
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
const { width, height } = canvas;
// 把坐标原点移到画布中心,y 轴向上为正
ctx.translate(0.5 * width, 0.5 * height);
ctx.scale(1, -1);
/**
* 生成正多边形的顶点
* @param {number} edges - 边数
* @param {number} x - 起始点 x 坐标
* @param {number} y - 起始点 y 坐标
* @param {number} step - 每条边的长度
* @returns {Vector2D[]} 顶点数组
*/
function regularShape(edges = 3, x, y, step) {
const ret = [];
// 外角(弧度):走完一条边后需要转的角度
const delta = Math.PI * (1 - (edges - 2) / edges);
let p = new Vector2D(x, y); // 当前位置
const dir = new Vector2D(step, 0); // 初始方向:指向正右方
ret.push(p); // 记录起点
for (let i = 0; i < edges; i++) {
// 关键一步:沿着当前方向走一步,然后旋转外角
p = p.copy().add(dir.rotate(delta));
ret.push(p);
}
return ret;
}
// 绘制函数
function draw(points, strokeStyle = 'black', fillStyle = null) {
ctx.strokeStyle = strokeStyle;
ctx.beginPath();
ctx.moveTo(...points[0]);
for (let i = 1; i < points.length; i++) {
ctx.lineTo(...points[i]);
}
ctx.closePath();
if (fillStyle) {
ctx.fillStyle = fillStyle;
ctx.fill();
}
ctx.stroke();
}
// 绘制四个正多边形
// 三角形(3 条边)
draw(regularShape(3, 128, 128, 100));
// 六边形(6 条边)
draw(regularShape(6, -64, 128, 50));
// 十一边形(11 条边)
draw(regularShape(11, -64, -64, 30));
// 六十边形(60 条边,看起来几乎是个圆)
draw(regularShape(60, 128, -64, 6));
3.4 逐行拆解关键代码
const delta = Math.PI * (1 - (edges - 2) / edges);
这个公式看起来复杂,我们来推导一下:
内角 = (edges - 2) * 180 deg / edges // 多边形内角和公式
外角 = 180 deg - 内角 = 180 deg - (edges - 2) * 180 deg / edges
= 180 deg * (1 - (edges - 2) / edges)
= 360 deg / edges // 化简后!
所以 delta 本质上就是 2pi / edges,即外角的弧度值。
p = p.copy().add(dir.rotate(delta));
这行代码是核心:
dir.rotate(delta):把方向向量旋转外角(注意:这会改变dir本身!)p.copy().add(...):从当前位置出发,加上旋转后的方向向量,得到新位置p.copy()很重要——我们要保留旧位置作为顶点,新位置是下一个顶点
3.5 常见误区
| 误区 | 真相 |
|---|---|
| "边数越多,边长要越大" | 恰恰相反,边数多时每条边要更短,否则图形会超出画布 |
| "正多边形必须从中心开始画" | 可以从任意顶点开始,只要转对角度就能闭合 |
"dir.rotate() 不会修改原向量" |
rotate() 会修改自身!如果下次循环还需要原始方向就会出错 |
3.6 动手试一试
练习 3.1:修改代码,绘制一个正五边形(五角星的轮廓)。
点击查看答案
draw(regularShape(5, 0, 0, 80));
正五边形的外角是 360/5 = 72 度。注意:这不是五角星(五角星需要不同的绘制逻辑)。
练习 3.2:当边数变成 1000 时,图形会变成什么?为什么?
点击查看答案
会变成非常接近圆的图形。因为当边数趋近无穷大时,正多边形趋近于圆。这就是为什么我们用 60 边形来近似画圆。
4. 递归分形树
4.1 生活类比:一棵树的生长
观察自然界中的树:
- 从地面长出一根主干
- 主干顶端分叉,长出两根较细的树枝
- 每根树枝顶端又分叉,长出更细的树枝
- 重复这个过程,直到树枝细到无法继续分叉
这就是递归——同样的事情重复做,但每次规模更小,直到满足某个停止条件。
递归的本质:把大问题拆成结构相同的子问题,子问题再拆成更小的子问题,直到可以直接求解。
4.2 分形树的数学原理
分形树的核心规则:
- 画一条线段(树干/树枝)
- 在线段末端,向左偏转一定角度,画一条更短的线段
- 在线段末端,向右偏转一定角度,画一条更短的线段
- 对每条新线段重复步骤 2-3
- 停止条件:当线段长度或粗细小于阈值时停止
每次递归,线段长度乘以一个小于 1 的系数(如 0.9),粗细也相应减小。
4.3 代码实现
下面是 vector_tree/app.js 的完整代码:
import { Vector2D } from '../common/lib/vector2d.js';
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
// 坐标变换:原点在左下角,y 轴向上
ctx.translate(0, canvas.height);
ctx.scale(1, -1);
ctx.lineCap = 'round'; // 线段末端圆润,更像树枝
/**
* 递归绘制树枝
* @param {CanvasRenderingContext2D} context - Canvas 上下文
* @param {Vector2D} v0 - 起点
* @param {number} length - 线段长度
* @param {number} thickness - 线段粗细
* @param {number} dir - 方向角度(弧度)
* @param {number} bias - 随机偏移系数
*/
function drawBranch(context, v0, length, thickness, dir, bias) {
// 1. 计算终点:从起点出发,沿 dir 方向走 length 距离
const v = new Vector2D().rotate(dir).scale(length);
const v1 = v0.copy().add(v);
// 2. 画线段
context.lineWidth = thickness;
context.beginPath();
context.moveTo(...v0);
context.lineTo(...v1);
context.stroke();
// 3. 递归停止条件:当粗细小于 2 时不再分叉
if (thickness > 2) {
// 左分支:方向偏左,加入随机扰动
const left = Math.PI / 4 + 0.5 * (dir + 0.2) + bias * (Math.random() - 0.5);
drawBranch(context, v1, length * 0.9, thickness * 0.8, left, bias * 0.9);
// 右分支:方向偏右,加入随机扰动
const right = Math.PI / 4 + 0.5 * (dir - 0.2) + bias * (Math.random() - 0.5);
drawBranch(context, v1, length * 0.9, thickness * 0.8, right, bias * 0.9);
}
// 4. 画花朵(随机出现)
if (thickness < 5 && Math.random() < 0.3) {
context.save();
context.strokeStyle = '#c72c35'; // 红色
const th = Math.random() * 6 + 3;
context.lineWidth = th;
context.beginPath();
context.moveTo(...v1);
context.lineTo(v1.x, v1.y - 2); // 向上画一小段
context.stroke();
context.restore();
}
}
// 从画布底部中央开始画树
const v0 = new Vector2D(256, 0);
drawBranch(ctx, v0, 50, 10, 1, 3);
4.4 逐行拆解
关键一行:计算终点
const v = new Vector2D().rotate(dir).scale(length);
new Vector2D()创建默认向量(1, 0).rotate(dir)把它旋转到指定方向.scale(length)把长度缩放到指定值
最终 v 是一个从原点出发、方向为 dir、长度为 length 的向量。
const v1 = v0.copy().add(v);
把位移向量 v 加到起点 v0 上,得到终点 v1。
递归调用
drawBranch(context, v1, length * 0.9, thickness * 0.8, left, bias * 0.9);
每次递归:
- 起点变成当前线段的终点
v1 - 长度变为原来的 90%
- 粗细变为原来的 80%
- 随机系数也衰减,让后面的分叉更稳定
4.5 常见误区
| 误区 | 真相 |
|---|---|
| "递归就是循环" | 递归是函数调用自身,有栈帧开销;循环没有。递归更适合处理"树形结构"问题 |
| "没有停止条件也能递归" | 没有停止条件会导致栈溢出(Stack Overflow),程序崩溃 |
| "分形树必须左右对称" | 加入随机性后,左右分支可以不对称,反而更像真实的树 |
| "每次分叉必须恰好分成两支" | 可以分成 3 支、4 支,甚至随机决定分支数量 |
4.6 动手试一试
练习 4.1:修改代码,让树分出 3 个分支而不是 2 个。
点击查看答案
if (thickness > 2) {
// 中间分支
drawBranch(context, v1, length * 0.9, thickness * 0.8, dir, bias * 0.9);
// 左分支
const left = dir + Math.PI / 6 + bias * (Math.random() - 0.5);
drawBranch(context, v1, length * 0.9, thickness * 0.8, left, bias * 0.9);
// 右分支
const right = dir - Math.PI / 6 + bias * (Math.random() - 0.5);
drawBranch(context, v1, length * 0.9, thickness * 0.8, right, bias * 0.9);
}
练习 4.2:如果把 length * 0.9 改成 length * 0.5,树会变成什么样?
点击查看答案
树枝会迅速变短,树看起来更"矮胖",分叉更密集。因为每次递归长度衰减更快,所以在同样的粗细阈值下会进行更多次分叉。
5. 海龟绘图系统
5.1 生活类比:牵着乌龟画画
想象你有一只听话的乌龟,它背着一支笔在地上爬行:
pen(2, 'red'):换一支红色、粗细为 2 的笔put(0, 0):把乌龟放到坐标(0, 0)forward(100):乌龟向前走 100 步,同时画出轨迹right(90):乌龟向右转 90 度
这就是海龟绘图(Turtle Graphics),1967 年为教育目的发明,至今仍是学习图形编程的最佳入门工具。
海龟绘图的本质:维护一个"画笔状态"(位置 + 方向),通过命令改变状态并记录轨迹,最后统一渲染。
5.2 系统架构
海龟绘图系统分为两层:
- 命令层(
doodle.js):记录画笔命令,不直接画图 - 渲染层(
index.html中的render回调):把命令翻译成实际的绘图操作
这种分离的好处是:同样的命令序列可以用不同的方式渲染(Canvas、SVG、甚至 DOM div)。
5.3 doodle.js 的实现
// akira-graphics/sketch/lib/doodle.js
const commandList = [];
const position = [0, 0]; // 当前位置 (x, y)
const direction = [1, 0]; // 当前方向 (dx, dy),初始指向正右方
// 旋转向量 (dx, dy) by deg 度
function rotate(deg) {
const rad = deg * Math.PI / 180;
const c = Math.cos(rad);
const s = Math.sin(rad);
const [x, y] = direction;
direction[0] = x * c + y * -s;
direction[1] = x * s + y * c;
}
// 设置画笔
export function pen(lineWidth, color = 'black') {
commandList.push({
command: 'pen',
args: { color, lineWidth },
});
put(...position); // 自动落笔到当前位置
}
// 移动画笔到指定位置(不画线)
export function put(x, y) {
position[0] = x;
position[1] = y;
commandList.push({
command: 'put',
args: { x, y },
});
}
// 左转
export function left(deg) {
rotate(deg);
}
// 右转
export function right(deg) {
rotate(-deg);
}
// 前进(画出线段)
export function forward(length) {
// 根据当前方向和长度计算新位置
position[0] += length * direction[0];
position[1] += length * direction[1];
commandList.push({
command: 'forward',
args: {
x: position[0],
y: position[1],
},
});
}
// 渲染:把命令列表传给回调函数处理
export function render(parser) {
parser([...commandList]);
commandList.length = 0; // 清空命令列表
}
5.4 使用示例:画一个旋转的正方形
import { pen, put, right, forward, render } from './lib/doodle.js';
// 画 180 个逐渐旋转的正方形,形成漂亮的图案
for (let i = 0; i < 180; i++) {
pen(1, `hsl(${2 * i}, 50%, 50%)`); // 颜色随角度变化
put(0, 0); // 回到中心
forward(0.5);
right(90);
forward(0.5);
right(90);
forward(0.5);
right(90);
forward(0.5);
right(90);
right(2); // 整体旋转 2 度,为下一次做准备
}
// 渲染到 Canvas
render((commands) => {
const canvas = document.createElement('canvas');
canvas.width = 512;
canvas.height = 512;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
// 坐标投影:把 [-1, 1] 映射到画布像素坐标
function project({ x, y }) {
return {
x: 0.5 * (x + 1) * canvas.width,
y: 0.5 * (1 - y) * canvas.height,
};
}
commands.forEach(({ command, args }, idx) => {
if (command === 'pen') {
if (idx > 0) ctx.stroke(); // 结束上一段路径
ctx.strokeStyle = args.color;
ctx.lineWidth = args.lineWidth;
ctx.beginPath();
} else if (command === 'put') {
const { x, y } = project(args);
ctx.moveTo(x, y);
} else if (command === 'forward') {
const { x, y } = project(args);
ctx.lineTo(x, y);
}
});
ctx.stroke(); // 最后一段路径
});
5.5 坐标投影详解
为什么需要 project 函数?
海龟绘图使用归一化坐标(范围约 [-1, 1]),而 Canvas 使用像素坐标(范围 [0, 512])。
// 归一化 x in [-1, 1] -> 像素 x in [0, width]
x_pixel = 0.5 * (x + 1) * width
// 归一化 y in [-1, 1] -> 像素 y in [0, height]
// 注意 (1 - y):因为 Canvas 的 y 轴向下,而我们希望 y 轴向上
y_pixel = 0.5 * (1 - y) * height
5.6 常见误区
| 误区 | 真相 |
|---|---|
"forward(-10) 会报错" |
forward 支持负数,表示向后退 |
"right(90) 和 left(-90) 效果不同" |
效果完全相同,只是旋转方向相反 |
"put 会画线" |
put 只移动画笔位置,不画线,相当于 "抬笔移动" |
| "命令会立即执行" | 命令只是被记录到列表中,render() 时才真正执行 |
5.7 动手试一试
练习 5.1:用海龟绘图画一个等边三角形。
点击查看答案
pen(2, 'blue');
put(0, 0);
forward(0.5);
right(120);
forward(0.5);
right(120);
forward(0.5);
等边三角形的外角是 120 度。
练习 5.2:用海龟绘图画一个圆(提示:走很多小段,每次转很小的角度)。
点击查看答案
pen(1, 'green');
put(0, 0.5); // 从顶部开始
const radius = 0.5;
const theta = 0.5 * Math.PI / 180; // 每次转 0.5 度
const step = 2 * radius * Math.sin(theta); // 小段长度
for (let i = 0; i < 360; i++) {
forward(step);
right(1); // 每次转 1 度
}
原理:把圆拆成 360 段,每段近似直线。小段长度用弦长公式 2*r*sin(theta/2) 计算。
6. L-系统基础
6.1 生活类比:植物的 DNA
想象你有一种特殊的"植物 DNA",由简单的字母组成:
F:向前走一步+:向左转 60 度-:向右转 60 度
一条 DNA 规则:F -> F+F--F+F
这意味着:每次遇到 F,就替换成 F+F--F+F。
从 F 开始,不断应用规则:
- 第 0 代:
F - 第 1 代:
F+F--F+F - 第 2 代:
F+F--F+F+F+F--F+F--F+F--F+F+F+F--F+F
把这个字符串"翻译"成海龟命令,就得到了科赫雪花(Koch Snowflake)!
L-系统的本质:用字符串重写规则生成复杂图案。"L"来自林德梅耶(Lindenmayer),一位生物学家,他用这个系统模拟植物生长。
6.2 科赫雪花的代码实现
import { pen, left, right, forward, render } from './lib/doodle.js';
const depth = 5; // 递归深度
pen(1);
// 递归绘制科赫曲线的一段
function fractal(length, depth) {
if (depth <= 0) {
// 递归终止:直接向前走
forward(length);
return;
}
const l = length / 3; // 每段分成三份
// F -> F+F--F+F
fractal(l, depth - 1); // F
left(60); // +
fractal(l, depth - 1); // F
right(120); // -
fractal(l, depth - 1); // F
left(60); // +
fractal(l, depth - 1); // F
}
// 画三条科赫曲线组成雪花
left(60);
fractal(1, depth);
right(120);
fractal(1, depth);
right(120);
fractal(1, depth);
// 渲染...
6.3 规则拆解
F -> F+F--F+F 的图形含义:
/\
/ \
____/ \____
把一条直线段替换成"凸起的四段",就得到了科赫曲线的基本单元。递归地应用这个替换,直线段越来越复杂,最终形成雪花状的边界。
6.4 L-系统的通用结构
任何 L-系统都包含三个部分:
- 字母表:
F(前进)、+(左转)、-(右转)、[(保存状态)、](恢复状态)... - 初始字符串(公理):如
F - 产生规则:如
F -> F+F--F+F
6.5 常见误区
| 误区 | 真相 |
|---|---|
| "L-系统只能画分形" | L-系统可以模拟各种植物形态,甚至建筑结构 |
| "递归深度越大越好" | 深度太大时,线段数量指数增长,可能导致性能问题 |
"[ 和 ] 是可选的" |
对于分支结构(如树),必须用 [] 保存/恢复状态 |
6.6 动手试一试
练习 6.1:把规则改成 F -> F-F+F+F-F,看看画出什么图案。
点击查看提示
把 fractal 函数中的转向逻辑改成:
fractal(l, depth - 1);
right(60); // -
fractal(l, depth - 1);
left(60); // +
fractal(l, depth - 1);
left(60); // +
fractal(l, depth - 1);
right(60); // -
fractal(l, depth - 1);
这会生成不同的分形曲线。
7. 图形中的随机性
7.1 生活类比:没有两片相同的树叶
自然界中的树不是完美的——树枝的粗细、分叉的角度、花朵的位置都有细微差异。正是这种受控的随机性,让计算机生成的树看起来像真的,而不是机械的几何图形。
随机性的本质:不是完全混乱,而是在规则允许的范围内引入变化。
7.2 随机性的应用
在分形树中,我们在三个地方引入了随机性:
1. 分支角度
const left = Math.PI / 4 + 0.5 * (dir + 0.2) + bias * (Math.random() - 0.5);
Math.random() - 0.5 生成 [-0.5, 0.5] 的随机数,让每次分叉角度略有不同。
2. 分支长度和粗细
drawBranch(context, v1, length * 0.9, thickness * 0.8, left, bias * 0.9);
虽然衰减系数固定(0.9 和 0.8),但可以进一步加入随机:
const newLength = length * (0.8 + Math.random() * 0.2); // 0.8 ~ 1.0 之间
3. 花朵的随机出现
if (thickness < 5 && Math.random() < 0.3) {
// 30% 的概率画花朵
}
7.3 随机种子的重要性
Math.random() 每次运行结果都不同。如果你想复现某个漂亮的树,需要设置随机种子:
// 简单的伪随机数生成器(线性同余)
let seed = 12345;
function random() {
seed = (seed * 9301 + 49297) % 233280;
return seed / 233280;
}
// 用 random() 替代 Math.random(),只要 seed 相同,结果就相同
7.4 常见误区
| 误区 | 真相 |
|---|---|
| "随机性越多越好" | 过多的随机性会让图形失去结构感,看起来像一团乱麻 |
"Math.random() 是真正的随机" |
它是伪随机,由算法生成,有周期性 |
| "随机性只能用于位置" | 随机性还可以用于颜色、粗细、透明度、甚至递归深度 |
7.5 动手试一试
练习 7.1:修改分形树代码,让树枝颜色从棕色渐变到绿色。
点击查看答案
function drawBranch(context, v0, length, thickness, dir, bias, depth) {
// ... 计算终点 ...
// 根据深度计算颜色:深棕色 -> 浅绿色
const ratio = Math.min(depth / 10, 1);
const r = Math.floor(101 + (58 - 101) * ratio); // 101 -> 58
const g = Math.floor(67 + (120 - 67) * ratio); // 67 -> 120
const b = Math.floor(33 + (33 - 33) * ratio); // 33 -> 33
context.strokeStyle = `rgb(${r},${g},${b})`;
// ... 画线 ...
if (thickness > 2) {
drawBranch(context, v1, length * 0.9, thickness * 0.8, left, bias * 0.9, depth + 1);
// ...
}
}
// 初始调用时 depth = 0
drawBranch(ctx, v0, 50, 10, 1, 3, 0);
8. 常见问题 Q&A
Q1:为什么 Canvas 的 y 轴向下,而我们经常把它翻转成向上?
A:数学中的坐标系 y 轴向上,而 Canvas 的 y 轴向下(屏幕坐标系的惯例)。通过 ctx.scale(1, -1) 翻转 y 轴后,我们可以用更自然的数学思维来思考图形。
Q2:向量旋转公式中的 -s 和 +s 怎么记?
A:记住一个口诀:"cos 对角,sin 反对角"。逆时针旋转矩阵是:
[ cos -sin ]
[ sin cos ]
第一行是 x' = x*cos - y*sin,第二行是 y' = x*sin + y*cos。
Q3:递归和循环有什么区别?什么时候用递归?
A:
- 循环:适合线性重复(如遍历数组)
- 递归:适合树形/分支结构(如分形、文件目录遍历)
递归的代价是函数调用开销和栈空间。如果递归深度超过几千层,可能会栈溢出。
Q4:为什么 regularShape 返回 edges + 1 个点?
A:因为起点被 push 了一次,循环中又 push 了 edges 次。最后一个点和起点重合(正多边形闭合),所以 closePath() 可以正确闭合图形。
Q5:L-系统中的 [ 和 ] 是什么意思?
A:[ 保存当前画笔状态(位置和方向),] 恢复之前保存的状态。这让海龟可以在画完一个分支后"回到分叉点"继续画其他分支。例如画树:
F -> F[+F]F[-F]F
含义:前进,保存状态,左转画分支,恢复状态,前进,保存状态,右转画分支,恢复状态,前进。
Q6:如何调试递归函数?
A:在递归函数开头打印当前参数:
function drawBranch(context, v0, length, thickness, dir, bias) {
console.log('drawBranch:', { length, thickness, dir });
// ...
}
观察参数如何变化,确认递归在朝终止条件收敛。
Q7:为什么分形树看起来不对称?
A:因为代码中引入了 Math.random()。如果你希望对称的树,去掉随机项:
// 原来(随机):
const left = Math.PI / 4 + 0.5 * (dir + 0.2) + bias * (Math.random() - 0.5);
// 对称版本:
const left = Math.PI / 4 + 0.5 * (dir + 0.2);
Q8:如何提高 Canvas 绘图性能?
A:
- 减少
beginPath()/stroke()的调用次数,尽量批量绘制 - 使用
requestAnimationFrame做动画 - 对于静态图形,可以画到离屏 Canvas 上,然后
drawImage到主 Canvas - 避免在每一帧创建大量对象
总结
本章我们学习了:
| 概念 | 核心要点 |
|---|---|
| 向量 | 方向 + 大小,不是坐标点 |
| Vector2D | 封装向量的运算,支持链式调用 |
| 正多边形 | 外角 = 360 deg/边数,走一段转一下 |
| 递归分形树 | 大问题拆成相同的子问题,直到满足停止条件 |
| 海龟绘图 | 记录命令 + 统一渲染,分离逻辑与表现 |
| L-系统 | 字符串重写规则生成分形 |
| 随机性 | 受控的随机让图形更自然 |
这些概念是计算机图形学的基础,掌握它们后,你可以:
- 绘制任意正多边形和星形
- 用递归创造复杂的自然图案
- 构建自己的绘图 DSL(领域特定语言)
- 理解游戏引擎中向量数学的应用
继续加油,下一章我们将进入更精彩的变换与动画世界!
参考文件
akira-graphics/common/lib/vector2d.js—— Vector2D 类实现akira-graphics/vector_draw/app.js—— 正多边形绘制akira-graphics/vector_tree/app.js—— 递归分形树akira-graphics/sketch/lib/doodle.js—— 海龟绘图命令系统akira-graphics/sketch/index3.html—— 科赫雪花(L-系统)akira-graphics/sketch/index7.html—— 海龟绘图分形树