第四章:向量绘图与递归图形

本章适合人群:有基础编程经验、对图形学感兴趣的初级开发者

学习目标:理解数学向量的本质,掌握用向量绘制几何图形的方法,学会用递归创造自然图案


目录

  1. 什么是向量?
  2. Vector2D 类:位置、方向与模长
  3. 用向量绘制正多边形
  4. 递归分形树:递归如何创造自然图案
  5. 海龟绘图系统:pen、forward、right
  6. L-系统基础:用字符串生成分形
  7. 图形中的随机性:让画面更自然
  8. 常见问题 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 生活类比:沿着罗盘转圈

想象你站在一片空地上,手里拿着一根绳子。你要在地上钉出一个正三角形:

  1. 向前走一段距离,钉下第一个桩
  2. 原地向左转 120 度
  3. 再走同样的距离,钉下第二个桩
  4. 再转 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));

这行代码是核心:

  1. dir.rotate(delta):把方向向量旋转外角(注意:这会改变 dir 本身!)
  2. p.copy().add(...):从当前位置出发,加上旋转后的方向向量,得到新位置
  3. 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 生活类比:一棵树的生长

观察自然界中的树:

  1. 从地面长出一根主干
  2. 主干顶端分叉,长出两根较细的树枝
  3. 每根树枝顶端又分叉,长出更细的树枝
  4. 重复这个过程,直到树枝细到无法继续分叉

这就是递归——同样的事情重复做,但每次规模更小,直到满足某个停止条件。

递归的本质:把大问题拆成结构相同的子问题,子问题再拆成更小的子问题,直到可以直接求解。

4.2 分形树的数学原理

分形树的核心规则:

  1. 画一条线段(树干/树枝)
  2. 在线段末端,向左偏转一定角度,画一条更短的线段
  3. 在线段末端,向右偏转一定角度,画一条更短的线段
  4. 对每条新线段重复步骤 2-3
  5. 停止条件:当线段长度或粗细小于阈值时停止

每次递归,线段长度乘以一个小于 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);
  1. new Vector2D() 创建默认向量 (1, 0)
  2. .rotate(dir) 把它旋转到指定方向
  3. .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 系统架构

海龟绘图系统分为两层:

  1. 命令层doodle.js):记录画笔命令,不直接画图
  2. 渲染层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-系统都包含三个部分:

  1. 字母表F(前进)、+(左转)、-(右转)、[(保存状态)、](恢复状态)...
  2. 初始字符串(公理):如 F
  3. 产生规则:如 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

  1. 减少 beginPath()/stroke() 的调用次数,尽量批量绘制
  2. 使用 requestAnimationFrame 做动画
  3. 对于静态图形,可以画到离屏 Canvas 上,然后 drawImage 到主 Canvas
  4. 避免在每一帧创建大量对象

总结

本章我们学习了:

概念 核心要点
向量 方向 + 大小,不是坐标点
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 —— 海龟绘图分形树