dice-games101-3d-renderer, 🎲 骰子,学习图形学!整活插件:用TypeScript从零构建一个软件光栅化渲染器 | 整活+学习项目,灵感来自闫令琪老师的 GAMES101 课程作业 | 没有 Three.js/WebGL,从矩阵乘法到PNG编码全部从头实现,零npm依赖

koishi-plugin-dice-games101-3d-renderer :game_die:

npm
npm-download

GitHub
Gitee

Koishi Forum
QQ群

TypeScript

:game_die: 骰子,学习图形学!整活插件:用TypeScript从零构建一个软件光栅化渲染器 :rocket:

整活+学习项目,灵感来自闫令琪老师的 GAMES101 课程作业

没有 Three.js or WebGL等图形库,从矩阵乘法到PNG编码全部从头实现,零npm依赖


:video_game: 快速上手

dice                        # 随机角度摇一次骰子
dice 30 20 10               # 指定 Yaw=30° Pitch=20° Roll=10°
dice --axis                 # 显示骰子本地坐标轴
dice 45 0 0 --axis          # 正Yaw 45° + 坐标轴可视化
dice 0 90 0                 # 俯仰 90°("鸟瞰"骰子顶部)
dice 67 67 67               # 🗿 神秘的 67° * 3
dice --ka 0.05 --kd 0.95    # 低环境光 + 强漫反射(高对比度)
dice --ks 2.0 -p 128        # 镜面高光 × 2 + 高锐度(闪亮效果)
dice -a --kd 0.5 --ks 0     # 坐标轴 + 纯 Lambert 漫反射(无高光)

敲回车后插件会实时渲染一个 400×400 的 3D 骰子图片发回来,同时告诉你渲染耗时和哪面朝上。


:camera_flash: 预览

dice 指令效果

图片
dice 渲染效果

OneBot 平台 / 普通消息模式


OneBot 平台

QQ 官方平台 / Markdown + LaTeX 模式


QQ 官方 Markdown


:hammer_and_wrench: 配置

配置项 默认 说明
showRenderInfo true 显示渲染耗时、角度和顶面点数
enableQuote true 是否引用触发消息
showAxis false 渲染骰子本地坐标轴
width 400 渲染图片宽度(像素,100-1000)
height 400 渲染图片高度(像素,100-1000)
ambient 0.15 环境光系数 k_a(Lambert)
diffuse 0.85 漫反射系数 k_d(Lambert)
specular 0.6 镜面高光系数 k_s(Blinn-Phong)
shininess 32 高光锐度 p(Blinn-Phong)
enableQQMarkdown true 在 QQ 官方 Bot 平台发送图片时附带 Markdown + 按钮消息
diceMarkdownSteps ['1-rotation-matrices', '6-normal-matrix', '7-face-detection'] :triangular_ruler: QQ Markdown 展示的 LaTeX 计算步骤(复选框)

:file_folder: 项目结构

src/
├── index.ts            # 插件入口
├── config.ts           # 配置定义
├── usage.ts            # Koishi 控制台配置页面
├── qq.ts               # QQ Markdown + 键盘消息发送
├── command/
│   └── command-dice.ts # dice 指令(参数解析、管线编排)
├── markdown/
│   ├── types.ts        # MarkdownContext 类型 + StepKey
│   ├── md.ts           # buildDiceMarkdown 主入口
│   ├── all-steps.ts    # 10 步注册表 + 排序
│   ├── step0.ts ~ step9.ts  # [0]🔄角度转换 ~ [9]✨Blinn-Phong
├── models/
│   └── dice.ts         # 骰子几何 + 顶面 / 前向面检测
├── math/
│   ├── vec.ts          # Vec3 / Vec4 向量运算
│   └── mat.ts          # Mat4 矩阵(旋转、lookAt、透视、逆、法线矩阵)
├── view/
│   ├── rasterizer.ts   # 软件光栅化器(MVP→AABB→重心坐标→Z-buffer→透视校正插值)
│   └── shader.ts       # 片段着色器(Lambert + Blinn-Phong 光照 + UV 点数遮罩)
└── image/
    └── png.ts          # 纯手写 PNG 编码(IHDR/IDAT/IEND/zlib/CRC)

:package: 依赖

零图形库依赖。唯一外部依赖:Node.js 内置 zlib(PNG 压缩)+ Koishi 本体。



:world_map: 渲染管线总览

flowchart TD
    A["顶点数据\n(Vec3 局部坐标)"] --> B

    subgraph MVP ["MVP 变换(核心)"]
        B["Model 矩阵\nR_z · R_x · R_y"] --> C
        C["View 矩阵\nlookAt(eye, center, up)"] --> D
        D["Projection 矩阵\n透视投影 P"]
    end

    D --> E["透视除法\n(x,y,z,w) → NDC"]
    E --> F["视口变换\nNDC → 屏幕像素坐标"]

    subgraph RAST ["光栅化"]
        F --> G["包围盒裁剪\nAABB"]
        G --> H["边缘函数 / 重心坐标\nE_AB E_BC E_CA ≥ 0"]
        H --> I["Z-buffer 深度测试\nz < depthBuffer"]
    end

    I -->|"通过"| J["片段着色\nLambert + UV 点数判断"]
    I -->|"丢弃"| K["×"]

    J --> L["写入帧缓冲\nRGBA"]
    L --> M["PNG 编码\n→ Base64 → 发送"]

:world_map: 坐标系

         +Y (up)
          |     -Z (相机看的方向)
          |    /                ← 你在这儿,站在 (0, 0, 3)
          |   /
          |  /                  ← 看向原点 (0, 0, 0)
          | /
          |/_______________ +X
         /
        +Z (相机背后)
  • 摄像头 站在 (0, 0, 3),看向 (0, 0, 0) → 视线是 -Z
  • Up 方向+Y右侧+X

坐标系示意图


:1234: 齐次坐标:为什么要用 4D?

3D 空间中的平移不是线性变换,无法用 3×3 矩阵表示。引入第四个分量 $w$ 后,平移可以统一为矩阵乘法:

$$
\begin{pmatrix}x’\y’\z’\1\end{pmatrix}=
\begin{pmatrix}1&0&0&t_x\0&1&0&t_y\0&0&1&t_z\0&0&0&1\end{pmatrix}
\begin{pmatrix}x\y\z\1\end{pmatrix}
$$

这样,平移、旋转、缩放、投影都统一成 4×4 矩阵乘法,管线中所有变换可以预乘合并为一个矩阵。

规定:$w=1$ 表示点(位置),$w=0$ 表示向量(方向,不受平移影响)。

齐次坐标示意图


:dart: Yaw / Pitch / Roll:三个角度定姿态

// 旋转顺序:先偏航,再俯仰,最后翻滚
const model = Mat4.rotateZ(rl)
  .multiply(Mat4.rotateX(pt))
  .multiply(Mat4.rotateY(yw))
参数 中文 绕哪个轴 效果
yaw 偏航 Y 轴(上下轴) 骰子左右转,像在转盘上
pitch 俯仰 X 轴(左右轴) 骰子前后翻,像在点头
roll 翻滚 Z 轴(前后轴) 骰子自旋,像在歪头

不给参数时自动用 LCG 随机生成:

const seed = Date.now()
const lcg = (s: number) => (Math.imul(s, 1664525) + 1013904223) >>> 0
const yw = yaw ?? lcg(seed) % 360

LCG(线性同余生成器)是最简单的伪随机数算法,公式 $x_{n+1}=(ax_n+c)\mod m$,这里 $a=1664525$,$c=1013904223$,$m=2^{32}$。

Yaw / Pitch / Roll 三轴旋转


:triangular_ruler: 数学:MVP 变换

所有顶点依次经过三个变换:Model → View → Projection

MVP 变换四阶段

Model 矩阵:三轴旋转

旋转顺序为 R_z(roll) · R_x(pitch) · R_y(yaw),右乘表示先 yaw 后 pitch 再 roll(矩阵右结合)。

$$
R_y(\theta)=\begin{pmatrix}\cos\theta&0&\sin\theta&0\0&1&0&0\-\sin\theta&0&\cos\theta&0\0&0&0&1\end{pmatrix}
\quad
R_x(\theta)=\begin{pmatrix}1&0&0&0\0&\cos\theta&-\sin\theta&0\0&\sin\theta&\cos\theta&0\0&0&0&1\end{pmatrix}
$$

$$
R_z(\theta)=\begin{pmatrix}\cos\theta&-\sin\theta&0&0\\sin\theta&\cos\theta&0&0\0&0&1&0\0&0&0&1\end{pmatrix}
\qquad
M_{\text{model}}=R_z\cdot R_x\cdot R_y
$$

旋转矩阵的列向量就是旋转后的坐标轴方向,且 $R^T=R^{-1}$(正交矩阵),这保证了旋转不改变向量长度。

2D 旋转推导(以 $R_z$ 为例):单位向量 $\mathbf{e}_1=(1,0)$, $\mathbf{e}_2=(0,1)$ 旋转 $\theta$ 后落在:

$$
\mathbf{e}_1’ = (\cos\theta,;\sin\theta),\qquad
\mathbf{e}_2’ = (-\sin\theta,;\cos\theta)
$$

任意向量 $\mathbf{v}=x\mathbf{e}_1+y\mathbf{e}_2$ 旋转后展开:

$$
\mathbf{v}’ = x\mathbf{e}_1’+y\mathbf{e}_2’
= \begin{pmatrix}\cos\theta&-\sin\theta\\sin\theta&\cos\theta\end{pmatrix}\begin{pmatrix}x\y\end{pmatrix}
$$

这正是 $R_z$ 的左上 $2\times2$ 子块。$R_x$、$R_y$ 同理,只是"旋转所在平面"不同。

const model = Mat4.rotateZ(rl).multiply(Mat4.rotateX(pt)).multiply(Mat4.rotateY(yw))

View 矩阵:lookAt 推导

给定摄像机位置 $\mathbf{e}$(eye)、目标点 $\mathbf{c}$(center)、世界上方 $\mathbf{u}$(up),构建相机正交基:

$$
\hat{f}=\frac{\mathbf{c}-\mathbf{e}}{|\mathbf{c}-\mathbf{e}|},\quad
\hat{r}=\frac{\hat{f}\times\hat{u}}{|\hat{f}\times\hat{u}|},\quad
\hat{u}'=\hat{r}\times\hat{f}
$$

View 矩阵 = 旋转到相机空间 × 平移到原点(把相机挪到原点、摄像方向对齐 -Z):

$$
V=\underbrace{\begin{pmatrix}
r_x & r_y & r_z & 0\
u’_x & u’y & u’z & 0\
-f_x & -f_y & -f_z & 0\
0 & 0 & 0 & 1
\end{pmatrix}}
{\text{旋转基底}}
\cdot
\underbrace{\begin{pmatrix}
1&0&0&-e_x\
0&1&0&-e_y\
0&0&1&-e_z\
0&0&0&1
\end{pmatrix}}
{\text{平移到原点}}
=\begin{pmatrix}
r_x & r_y & r_z & -\hat{r}\cdot\mathbf{e}\
u’_x & u’_y & u’_z & -\hat{u}'\cdot\mathbf{e}\
-f_x & -f_y & -f_z & \hat{f}\cdot\mathbf{e}\
0 & 0 & 0 & 1
\end{pmatrix}
$$

注意前向轴取 $-\hat{f}$(右手坐标系,相机看向 $-Z$)。

const view = Mat4.lookAt(new Vec3(0,0,3), new Vec3(0,0,0), new Vec3(0,1,0))

Projection 矩阵:透视投影推导

视锥体参数:竖直视角 $\text{fov}$,宽高比 $a$,近平面 $n$,远平面 $f$。

先求视锥体近平面的半高 $t$ 和半宽 $r$:

$$
t = n \cdot \tan!\left(\frac{\text{fov}}{2}\right),\qquad r = a \cdot t
$$

透视投影矩阵的推导思路:将视锥体内的点压缩到 NDC 立方体 $[-1,1]^3$。
$X/Y$ 方向直接除以 $r/t$ 归一化;$Z$ 方向需要保留深度排序关系,同时把 $w=-z_\text{view}$(用于透视除法):

$$
P=\begin{pmatrix}
\dfrac{n}{r} & 0 & 0 & 0\
0 & \dfrac{n}{t} & 0 & 0\
0 & 0 & \dfrac{-(f+n)}{f-n} & \dfrac{-2fn}{f-n}\
0 & 0 & -1 & 0
\end{pmatrix}
=\begin{pmatrix}
\dfrac{1}{a\cdot\tan(\text{fov}/2)} & 0 & 0 & 0\
0 & \dfrac{1}{\tan(\text{fov}/2)} & 0 & 0\
0 & 0 & \dfrac{-(f+n)}{f-n} & \dfrac{-2fn}{f-n}\
0 & 0 & -1 & 0
\end{pmatrix}
$$

第 3 行推导:要把 $z\in[-n,-f]$(视图空间,相机前方为负)映射到 NDC $[-1,+1]$,设映射为 $z_\text{NDC}=\frac{Az+B}{-z}$,代入边界条件:

  • $z=-n \Rightarrow z_\text{NDC}=-1$
  • $z=-f \Rightarrow z_\text{NDC}=+1$

解得 $A=-\dfrac{f+n}{f-n}$,$B=-\dfrac{2fn}{f-n}$。

const proj = Mat4.perspective(45, 1, 0.1, 100)

透视投影视锥体


:telescope: 透视除法与视口变换

透视除法:Clip 坐标 $(x_c,y_c,z_c,w_c)$ 除以 $w_c$ 得到 NDC(Normalized Device Coordinates):

$$
\mathbf{p}_{\mathrm{NDC}}=\left(\frac{x_c}{w_c},;\frac{y_c}{w_c},;\frac{z_c}{w_c}\right)\in[-1,1]^3
$$

由投影矩阵最后一行,$w_c = -z_{\rm view}$,即 $w_c > 0$(相机前方物体 $z_{\rm view}<0$)。这一步实现了近大远小的视觉效果。

视口变换(NDC → 屏幕像素,Y 轴翻转因为屏幕 Y 轴向下):

$$
x_s=\frac{x_{\text{NDC}}+1}{2}\cdot W,\qquad y_s=\frac{1-y_{\text{NDC}}}{2}\cdot H
$$

透视除法与视口变换


:small_red_triangle: 光栅化:边缘函数 + 重心坐标 + Z-buffer

边缘函数(Edge Function)

对屏幕三角形 $A,B,C$,定义三条边的边缘函数(叉积 $z$ 分量):

$$
E_{AB}(P)=(B-A)\times(P-A),\quad
E_{BC}(P)=(C-B)\times(P-B),\quad
E_{CA}(P)=(A-C)\times(P-C)
$$

点 $P$ 在三角形内部当且仅当三个函数符号一致(同正或同负,取决于绕向):

$$
P \in \triangle ABC \iff E_{AB}(P)\geq 0 ;\land; E_{BC}(P)\geq 0 ;\land; E_{CA}(P)\geq 0
$$

边缘函数还可以直接给出重心坐标:

$$
\alpha = \frac{E_{BC}(P)}{E_{BC}(A)},\quad
\beta = \frac{E_{CA}(P)}{E_{CA}(B)},\quad
\gamma = \frac{E_{AB}(P)}{E_{AB}(C)}
\quad\Longrightarrow\quad \alpha+\beta+\gamma=1
$$

遍历策略:取包围盒(AABB)内所有像素,用边缘函数快速跳过外点,避免逐像素遍历全图。

光栅化:AABB 包围盒 + 重心坐标

透视校正插值

屏幕空间的 $(\alpha,\beta,\gamma)$ 是经过透视失真的,直接插值 UV 或法线会出错(近处拉伸)。

为什么要校正:透视投影后,属性在屏幕空间的插值不再线性。根本原因是透视除法引入了非线性($w$ 随深度变化),直接插值 UV 或法线会在近处被拉伸。

证明 $f/w$ 在屏幕空间线性:设三角形顶点在裁剪空间的坐标为 $P_i$,对应属性 $f_i$,裁剪空间 $w$ 值为 $w_i = -z_{\text{view},i} > 0$。

3D 空间中 $f$ 是线性的,即 $f(\lambda) = \lambda_0 f_0 + \lambda_1 f_1 + \lambda_2 f_2$($\lambda_i$ 为3D重心坐标)。

透视投影后,屏幕重心坐标 $(\alpha,\beta,\gamma)$ 与 3D 坐标 $(\lambda_i)$ 的关系为(可由相似三角形推导):

$$
\lambda_i = \frac{\alpha_i / w_i}{\alpha_0/w_0 + \alpha_1/w_1 + \alpha_2/w_2}
$$

代入 $f$ 的线性插值:

$$
f_P = \sum_i \lambda_i f_i = \frac{\sum_i \alpha_i \cdot f_i/w_i}{\sum_i \alpha_i/w_i}
$$

其中 $\sum_i \alpha_i/w_i$ 在屏幕空间线性($1/w$ 可直接用 $\alpha,\beta,\gamma$ 插值),故分子和分母都可先线性插值,再相除:

$$
f_P=\frac{\alpha,\dfrac{f_0}{w_0}+\beta,\dfrac{f_1}{w_1}+\gamma,\dfrac{f_2}{w_2}}{\alpha,\dfrac{1}{w_0}+\beta,\dfrac{1}{w_1}+\gamma,\dfrac{1}{w_2}}
$$

透视校正插值 vs 线性插值

Z-buffer 深度测试

插值得到的深度 $z_P$ 与缓冲区比较,通过则写入,否则丢弃(靠近相机的片段胜出):

const zInterp = perspCorrect(z0, z1, z2, w0, w1, w2, α, β, γ)
if (zInterp < depthBuffer[x][y]) {
  depthBuffer[x][y] = zInterp
  // 写入颜色
  framebuffer[x][y] = shade(fragNormal, fragUV, baseColor)
}

Z-buffer 深度测试


:bulb: 片段着色:Lambert 漫反射

Lambert 光照模型漫反射 + 环境光):

$$
L = k_a I_a + k_d \max(0,;\hat{n}\cdot\hat{l})\cdot I_d
$$

符号 含义
$k_a$ 环境光系数(本项目默认值取 0.15)
$k_d$ 漫反射系数(本项目默认值取 0.85)
$\hat{n}$ 片段法线(须经法线矩阵变换,见下)
$\hat{l}$ 指向光源的单位向量((1,1,1) 归一化)
$I_a, I_d$ 环境光/漫反射光强度

$\hat{n}\cdot\hat{l}$ 是入射角余弦——越正对光源越亮,背光面 $\leq 0$ 截断为 0(截断保证不出现负光)。

const lightDir = new Vec3(1, 1, 1).normalized()
const diff = Math.max(0, normal.dot(lightDir))
const color = baseColor * (0.15 + diff * 0.85)

Lambert 漫反射光照模型

扩展:完整 Blinn-Phong 模型

本项目用简化 Lambert,完整的 Blinn-Phong 模型还包含镜面高光项,并引入光源距离衰减(平方反比定律):

$$
L = L_a + \sum_{\text{lights}} \left(L_d + L_s\right)
$$

$$
L_a = k_a \cdot I_a
\qquad
L_d = k_d \cdot \frac{I}{r^2} \cdot \max!\left(0,; \hat{n}\cdot\hat{l}\right)
\qquad
L_s = k_s \cdot \frac{I}{r^2} \cdot \max!\left(0,; \hat{n}\cdot\hat{h}\right)^p
$$

其中:

  • $r$ 为片段到光源的距离,$I/r^2$ 是平方反比衰减(物理准确)
  • $\hat{h} = \text{normalize}(\hat{l}+\hat{v})$ 为半角向量(Blinn 近似,$\hat{v}$ 为视线方向)
  • $p$ 为高光指数(shininess),越大高光越集中
  • $k_s$ 为镜面反射系数

半角向量的几何意义:$\hat{h}$ 是入射光 $\hat{l}$ 与视线 $\hat{v}$ 的角平分线。$\hat{n}\cdot\hat{h}$ 越大,说明法线越接近半角向量,即反射光越接近视线方向,高光越强。

对比原始 Phong(用 $\hat{r}\cdot\hat{v}$ 代替 $\hat{n}\cdot\hat{h}$,$\hat{r}$ 为反射向量):

$$
\hat{r} = 2(\hat{n}\cdot\hat{l})\hat{n} - \hat{l}
$$

Blinn-Phong 用半角向量替代反射向量,计算量更小且效果接近,是实时渲染的标准选择。

// cpp 的 Eigen 线性代数库
Eigen::Vector3f half_dir = (light_dir + view_dir).normalized();      // 半角向量
Eigen::Vector3f Ld = kd.cwiseProduct(I_r2) * max(0, n.dot(l_dir));   // 漫反射
Eigen::Vector3f Ls = ks.cwiseProduct(I_r2) * pow(max(0, n.dot(h)), p); // 高光

完整 Blinn-Phong 模型

法线变换矩阵:为什么用 $(M^{-1})^T$?

顶点用 $M$ 变换,法线不能直接用 $M$ 变换——若模型有非均匀缩放,直接用 $M$ 会导致法线不再垂直于表面。

推导:设切线向量 $\mathbf{t}$,法线 $\mathbf{n}$,满足 $\mathbf{n}^T\mathbf{t}=0$。
变换后切线 $\mathbf{t}'=M\mathbf{t}$,设法线变换矩阵为 $G$,要求 $(G\mathbf{n})^T(M\mathbf{t})=0$,即 $\mathbf{n}^T G^T M\mathbf{t}=0$。
要使此式对任意 $\mathbf{t}$ 成立,需 $G^T M = I$,即:

$$
G = (M^{-1})^T
$$

对纯旋转矩阵 $M=R$,由 $R^{-1}=R^T$ 可得 $G=R$,退化为直接用 $M$。本项目骰子只做旋转无缩放,所以法线可以直接经过 Model 矩阵旋转部分变换。

法线变换矩阵 (M⁻¹)ᵀ

点数判断(UV 圆形测试)

每个顶点携带 UV 坐标 $(u,v)\in[0,1]^2$,经透视校正插值后与预设圆心 $(c_x,c_y)$ 比较:

$$
(u-c_x)^2+(v-c_y)^2 < r^2 \implies \text{涂黑(点)}
$$

const isDot = (uv.x - dotCenter.x) ** 2 + (uv.y - dotCenter.y) ** 2 < 0.09 ** 2

骰子六面点数 UV 布局


:game_die: 顶面判断

骰子有 6 个面,每个面有一个初始法线(旋转前):

法线方向 点数 坐标轴颜色(--axis 备注
(0, 0, +1) → +Z 1 点 :blue_heart: 蓝正轴 初始朝向相机
(0, 0, -1) → -Z 6 点 :blue_heart: 蓝负轴 背面
(0, +1, 0) → +Y 2 点 :green_heart: 绿正轴 初始朝上
(0, -1, 0) → -Y 5 点 :green_heart: 绿负轴 底面
(+1, 0, 0) → +X 3 点 :heart: 红正轴 右面
(-1, 0, 0) → -X 4 点 :heart: 红负轴 左面

旋转后,把 6 个法线都通过 Model 矩阵的旋转部分转一遍,Y 分量最大的面即当前顶面

$$
\text{top} = \arg\max_{i}\left(M_{[1,:]}\cdot \hat{n}i\right)
= \arg\max
{i}\left(d_4,n_x^{(i)}+d_5,n_y^{(i)}+d_6,n_z^{(i)}\right)
$$

其中 $M_{[1,:]}$ 是 Model 矩阵第 2 行(行主序 d[4..6],对应 Y 轴变换方向)。

export function getTopFace(model: Mat4): number {
  const faces = [
    { nx: 0, ny: 0, nz: 1, value: 1 }, { nx: 0, ny: 0, nz: -1, value: 6 },
    { nx: 0, ny: 1, nz: 0, value: 2 }, { nx: 0, ny: -1, nz: 0, value: 5 },
    { nx: 1, ny: 0, nz: 0, value: 3 }, { nx: -1, ny: 0, nz: 0, value: 4 },
  ]
  const d = model.d
  let top = 1, maxY = -Infinity
  for (const f of faces) {
    const ty = d[4] * f.nx + d[5] * f.ny + d[6] * f.nz
    if (ty > maxY) { maxY = ty; top = f.value }
  }
  return top
}

顶面判断算法



:books: 参考

1 个赞

论坛里面渲染不了latex,请去github阅读喵:

1 个赞

前段时间花了十几个小时了解了一些图形学入门知识把,搞个插件稍微复习复习

1 个赞