GraphX:基于 WebGL 区间算术的 GPU 加速隐函数绘图器

GraphX:基于 WebGL 区间算术的 GPU 加速隐函数绘图器

目录


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 实现,分为三个阶段。

词法分析:隐式乘法

数学表达式中省略乘号是常见写法(如 2x2sin(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 无反应

问题handleExportuseCallback 依赖数组为空 [],导致回调永远捕获到初始的 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)
相关推荐
utmhikari2 小时前
【DIY小记】解决MacOS上Edge浏览器bilibili全屏卡顿的问题
前端·macos·性能优化·edge·bilibili
上单带刀不带妹2 小时前
UniApp 页面跳转完全指南:5 种路由方式详解与实战对比
前端·javascript·vue.js·uni-app·跨端开发
大阿明2 小时前
Node.js npm 安装过程中 EBUSY 错误的分析与解决方案
前端·npm·node.js
Cxiaomu2 小时前
Web 项目的开发/生产环境请求接口配置治理实战
前端·react.js·typescript
Можно2 小时前
深入理解 UniApp 生命周期钩子:从页面到组件的全流程掌控
前端·javascript·vue.js
easyboot2 小时前
使用element-plus的暗黑模式
javascript·vue.js·elementui
橙色日落2 小时前
Vue2 + LogicFlow 实现可视化流程图编辑功能+常用属性大全
前端·vue·流程图·logicflow
NaMM CHIN2 小时前
Spring boot整合quartz方法
java·前端·spring boot
西洼工作室2 小时前
react 地图找房模块
前端·react.js·前端框架