GraphX:基于 WebGL 区间算术的 GPU 加速隐函数绘图器
目录
- 背景与动机
- [核心算法:区间算术 + GPU](#核心算法:区间算术 + GPU)
- 为什么需要区间算术
- [从数学到 GLSL](#从数学到 GLSL)
- [GLSL 着片着色器:全部计算在 GPU 完成](#GLSL 着片着色器:全部计算在 GPU 完成)
- [数学表达式解析器:从字符串到 GPU 代码](#数学表达式解析器:从字符串到 GPU 代码)
- [WebGL 渲染引擎:TypeScript 管线](#WebGL 渲染引擎:TypeScript 管线)
- [React 架构:状态管理与组件设计](#React 架构:状态管理与组件设计)
- [Zustand 状态管理](#Zustand 状态管理)
- [Hydration 安全:SSR/CSR 一致性](#Hydration 安全:SSR/CSR 一致性)
- 组件层次与职责
- 主题系统与国际化
- [CSS 自定义属性驱动的双主题](#CSS 自定义属性驱动的双主题)
- [i18n:轻量级 zustand 方案](#i18n:轻量级 zustand 方案)
- 踩坑记录
- [
tan(x)=y的渐近线伪影](#tan(x)=y 的渐近线伪影) - [导出 PNG 无反应](#导出 PNG 无反应)
- [React Hydration 失败](#React Hydration 失败)
- [
- 技术栈
- 项目结构
Demo体验
https://graphx.space.z.ai/
https://graphx.space.z.ai/
https://graphx.space.z.ai/








背景与动机
传统数学绘图工具(如 Desmos、GeoGebra)通常采用 数值采样 + CPU 路径追踪 的方式绘制函数图像。这种方法对于 y = f ( x ) y = f(x) y=f(x) 类型的显函数效果很好,但对于 隐函数 f ( x , y ) = 0 f(x, y) = 0 f(x,y)=0 的绘制则面临挑战:
- 需要大量的等高线采样才能获得平滑的曲线
- 对 e sin ( x ) + cos ( y ) − sin ( e x + y ) = 0 e^{\sin(x)+\cos(y)} - \sin(e^{x+y}) = 0 esin(x)+cos(y)−sin(ex+y)=0 这类复杂方程,CPU 逐像素计算代价高昂
- 缩放到极深层次时,传统的自适应细分算法容易产生性能瓶颈
GraphX 采用了一种完全不同的方法:将区间算术运算完全卸载到 GPU 片段着色器,利用数千个 GPU 核心并行计算每个像素是否可能包含方程的零点。单个 GLSL 着色器调用即可完成整个屏幕的绘制,性能远超任何 CPU 方案。
本文将完整介绍这个项目的技术实现,从底层的区间算术 GLSL 库到上层的 React 状态管理。
核心算法:区间算术 + GPU
为什么需要区间算术
区间算术(Interval Arithmetic)是一种用 区间 而非单一数值进行计算的方法。对于函数 f ( x , y ) f(x, y) f(x,y),不再计算 f ( a , b ) f(a, b) f(a,b) 的具体值,而是计算 f f f 在矩形区域 [ x min , x max ] × [ y min , y max ] [x_{\min}, x_{\max}] \times [y_{\min}, y_{\max}] [xmin,xmax]×[ymin,ymax] 上的值域范围 [ v min , v max ] [v_{\min}, v_{\max}] [vmin,vmax]。
核心判定规则 :如果 0 ∈ [ v min , v max ] 0 \in [v_{\min}, v_{\max}] 0∈[vmin,vmax],即值域跨越零点,则该区域 可能 包含方程 f ( x , y ) = 0 f(x,y)=0 f(x,y)=0 的解。这意味着我们只需检查每个像素覆盖的坐标区间,即可判定是否需要着色。
像素 (px, py) → 坐标区间 [x₁,x₂]×[y₁,y₂]
↓
区间运算 evaluateEquation([x₁,x₂], [y₁,y₂])
↓
结果区间 [r₁, r₂]
↓
r₁ ≤ 0 ≤ r₂ ? → 着色 : 跳过
从数学到 GLSL
在 GLSL 中,我们用 vec2 表示一个区间,其中 .x 是下界,.y 是上界。每个基本运算都有一个对应的区间版本:
| 运算 | 点值 | 区间版本 |
|---|---|---|
| 加法 | a + b a + b a+b | [ a 1 + b 1 , a 2 + b 2 ] [a_1 + b_1,\ a_2 + b_2] [a1+b1, a2+b2] |
| 减法 | a − b a - b a−b | [ a 1 − b 2 , a 2 − b 1 ] [a_1 - b_2,\ a_2 - b_1] [a1−b2, a2−b1] |
| 乘法 | a × b a \times b a×b | [ min ( a b ) , max ( a b ) ] [\min(ab),\ \max(ab)] [min(ab), max(ab)](需要检查 4 个组合) |
| 除法 | a / b a / b a/b | a × [ 1 / b 2 , 1 / b 1 ] a \times [1/b_2,\ 1/b_1] a×[1/b2, 1/b1](除数不含 0 时) |
| 正弦 | sin ( x ) \sin(x) sin(x) | 需要检测极值点(检查区间是否跨越 π / 2 + n π \pi/2 + n\pi π/2+nπ) |
关键优势:所有运算在 GPU 上并行执行,每帧处理数百万个像素区间,零 CPU 开销。
GLSL 片段着色器:全部计算在 GPU 完成
整个绘图引擎的核心是一个 GLSL 片段着色器,每个像素独立执行以下流程:
区间运算函数库
我们实现了一整套 GLSL 区间运算函数:
glsl
// 区间加法:直接对应
vec2 iAdd(vec2 a, vec2 b) { return vec2(a.x + b.x, a.y + b.y); }
// 区间减法:交叉相减
vec2 iSub(vec2 a, vec2 b) { return vec2(a.x - b.y, a.y - b.x); }
// 区间乘法:穷举 4 种组合,取最小和最大值
vec2 iMul(vec2 a, vec2 b) {
vec4 p = vec4(a.x*b.x, a.x*b.y, a.y*b.x, a.y*b.y);
return vec2(min(min(p.x,p.y), min(p.z,p.w)),
max(max(p.x,p.y), max(p.z,p.w)));
}
// 正弦函数:检测极值点
vec2 iSin(vec2 a) {
if (a.y - a.x >= PI * 2.0) return vec2(-1.0, 1.0); // 覆盖完整周期
float s1 = sin(a.x), s2 = sin(a.y);
float vmin = min(s1, s2), vmax = max(s1, s2);
// 检查区间内是否有极大值点 (+1) 或极小值点 (-1)
float k1 = ceil((a.x - PI*0.5) / PI);
float k2 = floor((a.y - PI*0.5) / PI);
if (k1 <= k2) { if (mod(k1, 2.0) == 0.0) vmax = 1.0; else vmin = -1.0; }
return vec2(vmin, vmax);
}
iSin 的实现细节 :正弦函数的极值点出现在 π 2 + n π \frac{\pi}{2} + n\pi 2π+nπ 处。我们需要检查输入区间 [ a x , a y ] [a_x, a_y] [ax,ay] 是否跨越任何一个极值点。这通过计算跨越了多少个 π \pi π 周期来实现。
子像素细分与抗锯齿
直接对每个像素进行一次区间判断会导致曲线锯齿严重。我们采用 3×3 子像素细分 技术:
glsl
float hit = 0.0;
float N = 3.0;
vec2 step_size = pixel_size / N;
for (float i = 0.0; i < 3.0; i++) {
for (float j = 0.0; j < 3.0; j++) {
vec2 subX = vec2(uv_min.x + i*step_size.x, uv_min.x + (i+1.0)*step_size.x);
vec2 subY = vec2(uv_min.y + j*step_size.y, uv_min.y + (j+1.0)*step_size.y);
vec2 res = evaluateEquation(subX, subY);
if (res.x <= 0.0 && res.y >= 0.0) hit += 1.0; // 零穿越检测
}
}
float alpha = hit / (N * N); // 0.0 ~ 1.0 覆盖率
alpha = pow(alpha, 0.6) * u_lineWidth; // gamma 校正 + 线宽控制
每个像素被划分为 9 个子区域,分别进行区间判断。覆盖率 α \alpha α 通过 pow(alpha, 0.6) 进行 gamma 校正,使得部分覆盖的像素呈现半透明效果,从而产生自然的抗锯齿。
自适应网格与坐标轴
网格线的间距随缩放级别自适应调整,使用对数尺度计算:
glsl
float gridLog = log2(u_scale); // 缩放级别的对数
float gridExp = floor(gridLog); // 取整得到基准间距
float gridSpacing = exp2(gridExp); // 实际间距 = 2^gridExp
当 u_scale = 8 时,gridLog = 3,网格间距为 2 3 = 8 2^3 = 8 23=8。缩放时网格间距自动变为 4、2、1、0.5......始终保持视觉上的合理性。
数学表达式解析器:从字符串到 GPU 代码
用户输入的方程(如 e^(sin(x)+cos(y))-sin(e^(x+y))=0)需要被解析并转换为 GLSL 区间运算代码。这个解析器完全用 TypeScript 实现,分为三个阶段。
词法分析:隐式乘法
数学表达式中省略乘号是常见写法(如 2x、2sin(x))。词法分析器通过正则预处理自动插入乘号:
typescript
function tokenize(str: string): string[] {
// 2x → 2*x
str = str.replace(/(\d)([xy])/gi, '$1*$2');
// 2sin → 2*sin
str = str.replace(/(\d)([a-zA-Z])/g, '$1*$2');
// )( → )*(
str = str.replace(/\)\(/g, ')*(');
// )x → )*x
str = str.replace(/\)([xy])/gi, ')*$1');
return str.match(/\d+\.\d+|\d+|[a-zA-Z]+|[+\-*/^=()]/g);
}
递归下降语法分析
采用经典的递归下降解析器,按照运算符优先级组织语法规则:
Equation → Expression ('=' Expression)?
Expression → Term (('+' | '-') Term)*
Term → Power (('*' | '/') Power)*
Power → Unary ('^' Unary)*
Unary → ('-' | '+')? Atom
Atom → '(' Expression ')' | Function '(' Expression ')' | Variable | Constant | Number
每个语法规则函数返回一段 GLSL 代码字符串,而非 AST 节点。这种 直接代码生成 的方式避免了中间表示的开销:
typescript
function parseExpression(): string {
let node = parseTerm();
while (pos < tokens.length && (peek() === '+' || peek() === '-')) {
const op = consume() as string;
const right = parseTerm();
node = op === '+' ? `iAdd(${node}, ${right})` : `iSub(${node}, ${right})`;
}
return node;
}
AST 到 GLSL 代码生成
方程 x^2 + y^2 = 4 的完整转换过程:
输入: x^2 + y^2 = 4
↓ 词法分析
Tokens: ['x', '^', '2', '+', 'y', '^', '2', '=', '4']
↓ 语法分析
AST: iSub(iAdd(iPow(x, 2), iPow(y, 2)), vec2(4.000000))
↓ 代码生成
GLSL: vec2 evaluateEquation(vec2 x, vec2 y) {
return iSub(iAdd(iPow(x, vec2(2.000000)), iPow(y, vec2(2.000000))), vec2(4.000000));
}
注意 = 号的处理:f(x,y) = g(x,y) 被转换为 iSub(f, g),因为判定 f − g = 0 f - g = 0 f−g=0 等价于 f = g f = g f=g。
WebGL 渲染引擎:TypeScript 管线
渲染管线架构
用户输入方程
↓
TypeScript 解析器 → GLSL 代码字符串
↓
字符串模板替换 → 完整 Fragment Shader 源码
↓
WebGL 编译着色器 → 链接 Program
↓
设置 Uniforms (分辨率, 偏移, 缩放, 颜色, 线宽, 网格)
↓
drawArrays(GL_TRIANGLES, 0, 6) ← 全屏四边形,每个像素并行执行
整个管线被封装在 WebGLPlotterEngine 类中,对外暴露简洁的 API:
typescript
const engine = new WebGLPlotterEngine();
engine.initialize(canvas);
engine.setEquation('x^2 + y^2 = 4'); // 触发 shader 编译
engine.pan(dx, dy); // 拖拽平移
engine.zoom(1.2, mouseX, mouseY); // 焦点缩放
engine.captureFrame(); // 导出 PNG
焦点缩放与坐标系变换
缩放的核心挑战在于 保持鼠标指向的数学坐标不变 。当缩放因子为 f f f 时:
x o f f s e t ′ = x m o u s e − x m o u s e − x o f f s e t f x'{offset} = x{mouse} - \frac{x_{mouse} - x_{offset}}{f} xoffset′=xmouse−fxmouse−xoffset
typescript
zoom(factor: number, centerX?: number, centerY?: number): void {
const mouseMathX = screenToMathX(centerX);
const mouseMathY = screenToMathY(centerY);
const oldScale = this.state.scale;
this.state.scale *= factor;
const scaleRatio = this.state.scale / oldScale;
this.state.offsetX = mouseMathX - (mouseMathX - this.state.offsetX) * scaleRatio;
this.state.offsetY = mouseMathY - (mouseMathY - this.state.offsetY) * scaleRatio;
}
坐标系映射关系:
屏幕坐标 (px, py)
↓ [0, width] → [-1, 1] (NDC)
↓ × aspect × scale + offset
数学坐标 (mx, my)
React 架构:状态管理与组件设计
Zustand 状态管理
项目使用 4 个 Zustand store,各司其职:
| Store | 职责 | 持久化 |
|---|---|---|
usePlotterStore |
方程、历史记录、线条颜色/宽度、网格开关 | ✅ localStorage |
useEngineStore |
WebGL 引擎实例(跨组件共享) | ❌ |
useThemeStore |
亮色/暗色主题切换 | ✅ localStorage |
useI18nStore |
中英文语言切换 | ✅ localStorage |
引擎实例通过独立的 store 共享,避免了 React ref 的跨组件传递问题:
typescript
// engine-store.ts
export const useEngineStore = create((set) => ({
engine: null as WebGLPlotterEngine | null,
setEngine: (engine) => set({ engine }),
}));
// plotter-canvas.tsx --- 设置引擎
engineRef.current = engine;
setStoreEngine(engine);
// toolbar.tsx --- 读取引擎
const engine = useEngineStore((s) => s.engine);
engine?.zoom(1.4);
Hydration 安全:SSR/CSR 一致性
Next.js 的 SSR 会生成与客户端可能不一致的 HTML(特别是带有持久化 store 的场景),导致 React hydration 错误。我们采用 skipHydration + 客户端手动 rehydrate 的模式:
typescript
// store 创建时跳过自动水合
persist(create((set) => ({ ... })), {
name: 'plotter-storage',
skipHydration: true, // ← 关键
});
// 页面组件中使用 useSyncExternalStore 实现客户端守卫
const mounted = useSyncExternalStore(
() => () => {}, // subscribe(空操作)
() => true, // getSnapshot(客户端返回 true)
() => false // getServerSnapshot(服务端返回 false)
);
if (!mounted) return <Placeholder />; // 服务端渲染占位符
// 客户端挂载后手动 rehydrate
usePlotterStore.persist.rehydrate();
useThemeStore.persist.rehydrate();
useI18nStore.persist.rehydrate();
组件层次与职责
Home (page.tsx)
├── PlotterCanvas ← WebGL 画布 + 鼠标/触摸交互 + ResizeObserver
│ ├── <canvas> ← WebGL 渲染目标
│ └── AxisOverlay ← 2D Canvas 叠加层(坐标刻度、鼠标坐标、缩放倍率)
├── PlotterSidebar ← 玻璃态侧边栏(方程输入、预设、历史、设置)
└── PlotterToolbar ← 浮动工具栏(缩放、重置、适应视图)
双层 Canvas 架构:底层 WebGL Canvas 负责高性能数学渲染,上层 2D Canvas 负责坐标标注和鼠标信息显示。两层完全独立,互不干扰。
主题系统与国际化
CSS 自定义属性驱动的双主题
定义了 20+ 个绘图器专用的 CSS 变量,在 :root(亮色)和 .dark(暗色)中分别赋值:
css
:root {
--plotter-sidebar-bg: rgba(255, 255, 255, 0.92);
--plotter-accent: #0d9488; /* Teal-600 */
--plotter-text: #0f172a; /* Slate-900 */
--plotter-card-bg: #f1f5f9; /* Slate-100 */
}
.dark {
--plotter-sidebar-bg: rgba(12, 10, 9, 0.88);
--plotter-accent: #2dd4bf; /* Teal-400 */
--plotter-text: #fafaf9; /* Stone-50 */
--plotter-card-bg: rgba(255, 255, 255, 0.03);
}
组件中通过 style={``{ color: 'var(--plotter-accent)' }} 引用,无需任何 Tailwind dark: 前缀。画布背景始终保持深色(数学可视化的标准做法),仅 UI 控件随主题切换。
i18n:轻量级 zustand 方案
没有引入 next-intl 等重量级库,而是用一个简单的 zustand store + t(key, locale) 函数实现:
typescript
const translations = {
zh: { 'settings.export': '导出为 PNG', 'toolbar.zoomIn': '放大', ... },
en: { 'settings.export': 'Export as PNG', 'toolbar.zoomIn': 'Zoom In', ... },
};
export function t(key: string, locale: Locale): string {
return translations[locale]?.[key] ?? key;
}
整个 i18n 方案约 100 行代码,覆盖 ~45 个翻译键,支持 localStorage 持久化用户语言偏好。
踩坑记录
tan(x)=y 的渐近线伪影
问题 :iTan 在输入区间跨越 π 2 + n π \frac{\pi}{2} + n\pi 2π+nπ(正切函数的不连续点)时,原来返回 vec2(-1e6, 1e6)。这个区间永远包含 0,导致零穿越判定永远为真,渐近线处出现整条竖直色带。
修复 :跨越不连续点时返回 EMPTY_INTERVAL(下界 > 上界),使零穿越判定为假:
glsl
vec2 iTan(vec2 a) {
float k1 = floor((a.x - PI*0.5) / PI);
float k2 = floor((a.y - PI*0.5) / PI);
if (k1 != k2) return EMPTY_INTERVAL; // ← 修复:跨越渐近线
float t1 = tan(a.x), t2 = tan(a.y);
return vec2(min(t1, t2), max(t1, t2)); // ← 修复:保证下界 ≤ 上界
}
同理修复了 iDiv:除数区间包含 0 时也返回 EMPTY_INTERVAL。
导出 PNG 无反应
问题 :handleExport 的 useCallback 依赖数组为空 [],导致回调永远捕获到初始的 engine = null,点击时直接 return。
修复 :将 engine 加入依赖数组,并改用 Blob + URL.createObjectURL 替代直接设置 data: URL(部分浏览器环境不支持后者触发下载)。
React Hydration 失败
问题 :Zustand persist 中间件默认在水合阶段从 localStorage 读取数据,导致客户端和服务端渲染不一致。
修复 :设置 skipHydration: true,在客户端使用 useSyncExternalStore 做客户端守卫,挂载后手动调用 persist.rehydrate()。
技术栈
| 层级 | 技术 | 说明 |
|---|---|---|
| 框架 | Next.js 16 (App Router) | SSR + CSR |
| 语言 | TypeScript 5 | 全栈类型安全 |
| UI | Tailwind CSS 4 + shadcn/ui | 原子化 CSS + 组件库 |
| GPU 渲染 | WebGL (GLSL ES 1.0) | 片段着色器区间运算 |
| 状态管理 | Zustand | 轻量级、支持 persist 中间件 |
| 数学解析 | 自研递归下降解析器 | ~190 行 TypeScript |
| 主题 | CSS 自定义属性 + <html> class 切换 |
20+ 主题变量 |
| 国际化 | zustand store + t() 函数 |
中/英双语 |
项目结构
src/
├── app/
│ ├── globals.css ← 主题变量 + 自定义滚动条
│ ├── layout.tsx ← 根布局(SSR 安全)
│ └── page.tsx ← 主页面(Hydration 守卫)
├── components/plotter/
│ ├── webgl-engine.ts ← WebGL 渲染引擎(~350 行)
│ ├── plotter-canvas.tsx ← 画布组件 + 交互处理
│ ├── axis-overlay.tsx ← 坐标刻度叠加层
│ ├── sidebar.tsx ← 玻璃态侧边栏
│ └── toolbar.tsx ← 浮动工具栏
├── lib/
│ ├── glsl-interval.ts ← GLSL 区间算术库(~190 行)
│ └── math-parser.ts ← 数学解析器 + 预设方程(~320 行)
└── store/
├── plotter-store.ts ← 绘图器状态(persist)
├── engine-store.ts ← 引擎实例共享
├── theme-store.ts ← 主题状态(persist)
└── i18n-store.ts ← 国际化状态(persist)