女士们,先生们,欢迎来到这场关于“React 状态与 WebGL 缓冲区之爱恨情仇”的深度技术讲座。
我是你们今天的讲师,一个在 React 的虚拟世界里摸爬滚打,又在 WebGL 的像素海洋里差点溺水的高级工程师。
今天,我们不聊那些花里胡哨的 UI 组件,不聊那些让你头秃的 CSS Flexbox。今天,我们要聊聊 WebGL 的硬核内脏——顶点数据缓冲,以及 React 这个“唠叨”的家长是如何试图控制它的。我们将探讨一个极其性感且极具挑战性的话题:如何将 React 的声明式状态变更,转化为 WebGL 的命令式增量更新。
准备好了吗?让我们开始这场“内存拷贝”的狂欢。
第一章:当 React 遇到 WebGL —— 确认过眼神,是两个世界的人
首先,我们要认清现实。React 和 WebGL,这俩货就像是两个不同星球的居民。
React 是个典型的“乖宝宝”。它讲究声明式编程。你告诉它“我想让这个数字变成 100”,它就会乖乖地去修改虚拟 DOM,然后通过 Diffing 算法找出最小的变化路径,最后把那一点点 DOM 节点更新到屏幕上。它很聪明,但也很“慢”(相对于直接操作内存来说)。
WebGL 则是个暴躁的“老技工”。它是个命令式的怪兽。它不在乎你的数据结构是否优雅,它只在乎指令是否精准。你想画个三角形?行,给我 gl.drawArrays。你想改个顶点?行,给我 gl.bufferData。它就像一个只认死理的将军,你命令它,它才动。如果你不给它命令,它就在那儿干瞪眼,或者更糟,它正在后台疯狂地清空内存、重写数据。
痛点来了:
如果你在 React 的 useEffect 里,每当 state 变化就调用一次 gl.bufferData,会发生什么?
想象一下,你的 React 组件渲染了 10 次(因为父组件传了个新 prop),每次渲染都触发 useEffect,然后 gl.bufferData 就被调用 10 次。这意味着什么?意味着你的 GPU 被迫 10 次重新分配内存,10 次把数据从 CPU 拷贝到 GPU 显存。这就像你明明只需要给老板发一封邮件,结果你每次都把电脑重启一遍再发。
这不仅是浪费,这是对硬件的亵渎。
第二章:打破同步 —— useRef 的救赎
要解决这个问题,我们要做的第一件事就是打破同步。
React 的状态更新是同步的,它会触发组件重新渲染。而 WebGL 的操作必须在特定的生命周期中(比如 useEffect 的回调里)进行。我们不能在渲染函数里直接操作 WebGL,否则 React 会报错(或者更糟,性能会像蜗牛一样爬)。
我们需要一个“离屏”的地方来存放 WebGL 的上下文和当前的数据。
代码示例 1:建立 React 和 WebGL 的安全通道
import React, { useRef, useEffect, useState } from 'react';
const WebGLBufferComponent = () => {
// 1. useRef 是我们的秘密基地。它不会引起 React 的重新渲染。
const glRef = useRef(null);
const bufferRef = useRef(null);
// 模拟我们的顶点数据
const [vertices, setVertices] = useState(new Float32Array([0, 0, 0, 1, 1, 0]));
useEffect(() => {
const canvas = document.getElementById('glCanvas');
const gl = canvas.getContext('webgl');
if (!gl) return;
// 初始化 WebGL 上下文
glRef.current = gl;
// 创建 VBO (顶点缓冲对象)
const buffer = gl.createBuffer();
bufferRef.current = buffer;
// 第一次:把数据塞进去
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
// 渲染一帧
render(gl);
return () => {
// 清理工作(别忘的!)
gl.deleteBuffer(buffer);
};
}, []);
const render = (gl) => {
// ... 简单的着色器和绘制代码 ...
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
// 这里省略了 shader program 的 setup 和 draw call 的细节
};
const handleClick = () => {
// React 状态更新
setVertices(new Float32Array([1, 1, 1, 0, 0, 0, 0.5, 0.5, 0.5]));
};
return (
<div>
<button onClick={handleClick}>更新数据</button>
<canvas id="glCanvas" width={500} height={500} />
</div>
);
};
上面的代码是安全的,但它是低效的。每次点击按钮,vertices 状态改变,React 重新渲染,useEffect 再次执行,gl.bufferData 把整个数组从头到尾重写一遍。
第三章:数据结构 —— TypedArrays 的魔法
在深入增量更新之前,我们必须聊聊数据结构。这是 React 和 WebGL 的另一个冲突点。
React 喜欢用普通的 JavaScript 数组或对象来管理状态。
WebGL 喜欢用 TypedArrays(如 Float32Array, Uint16Array)。
普通的 JS 数组是动态的,可以随意 push,占用内存大,遍历慢。而 Float32Array 就像是内存里的一块预定义的“积木”,速度快,内存紧凑。
React 的痛点: 你想修改数组里的第 100 个元素。在 JS 数组里,你只能 arr[100] = newValue,这会触发 React 的 Diff 算法,导致整个组件树重新计算。
WebGL 的痛点: gl.bufferData 和 gl.bufferSubData 都是基于字节偏移量 的。
所以,我们的策略是:
- 在 React 状态里存一个
Float32Array(或者通过useMemo转换)。 - 在
useEffect里,我们只处理 WebGL 的“脏活累活”。
第四章:增量更新的核心 —— gl.bufferSubData
这是今天的重头戏。我们不想每次都 bufferData(重置),我们想用 bufferSubData(增量)。
gl.bufferData:清空缓冲区,然后写入新数据。开销大。
gl.bufferSubData:从缓冲区的某个偏移量开始,写入新数据。开销小。
那么,React 的状态变了,我们怎么知道 WebGL 的缓冲区哪里变了呢?
我们需要一个“守门员”。每次 React 状态更新时,我们都要比对“旧数据”和“新数据”。如果数据长度变了,我们就只能老老实实地 bufferData。如果长度没变,我们就遍历数组,找到变化的那个索引,然后只更新那个索引。
代码示例 2:实现增量更新逻辑
const updateBuffer = (gl, buffer, oldData, newData) => {
// 1. 长度检查:如果长度变了,那就没办法了,只能全量替换
if (oldData.length !== newData.length) {
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, newData, gl.DYNAMIC_DRAW);
return newData; // 返回新数据作为下一次的旧数据
}
// 2. 长度未变:开始寻找变化
// 注意:这是 CPU 端的遍历,非常快,因为 TypedArray 的遍历比 JS 数组快得多
let hasChanges = false;
let firstChangeIndex = -1;
// 我们只比较 Float32Array 的每个元素
for (let i = 0; i < newData.length; i++) {
if (newData[i] !== oldData[i]) {
hasChanges = true;
firstChangeIndex = i;
break; // 找到第一个变化就停止,因为 WebGL 的 bufferSubData 只能从 offset 开始写
}
}
if (hasChanges) {
// 计算字节偏移量:每个 float 是 4 个字节
const byteOffset = firstChangeIndex * 4;
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
// 呼叫 bufferSubData,只更新变化的这一小段
gl.bufferSubData(gl.ARRAY_BUFFER, byteOffset, newData.subarray(firstChangeIndex));
}
return newData;
};
这段代码的逻辑非常优雅:
它捕捉了 React 状态变化的“本质”。React 的状态更新通常是局部的(比如用户改了一个数字),我们利用这个局部性,只告诉 GPU 更新那一小块内存。
但是,上面的代码有个小瑕疵。如果数组很大,比如 10 万个顶点,而我们只改了第一个顶点,那么上面的 for 循环虽然只 break 了,但它是从 0 开始遍历的。对于大数据量,这其实还是有点浪费的。
不过,在现代 JS 引擎的优化下,对于几百到几千个顶点的数组,这种遍历是微不足道的。真正的性能瓶颈在于 gl.bindBuffer 和数据拷贝。
第五章:性能陷阱 —— bindBuffer 的隐形杀手
在 WebGL 中,gl.bindBuffer 是个昂贵的操作。它就像是去图书馆借书,每次都要去柜台排队。
每次我们调用 gl.bindBuffer(gl.ARRAY_BUFFER, buffer),GPU 都要暂停当前的任务,去处理这个请求。
在 React 的世界里,我们可能在一个渲染周期里多次修改状态,或者父组件频繁传递 props 导致子组件频繁重渲染。
优化策略:保持绑定状态。
一旦我们绑定了一个缓冲区,如果在下一次更新中我们还是更新这个缓冲区,我们就不要再次调用 bindBuffer!
const updateBufferOptimized = (gl, buffer, oldData, newData) => {
// ... 前面的 length 检查逻辑 ...
if (hasChanges) {
// 只有当 buffer 还没有绑定,或者绑定的不是这个 buffer 时,才去 bind
// 这里需要维护一个额外的 state 来记录当前绑定的 buffer id,或者利用 gl 的状态
// 简单起见,假设我们通过某种方式知道当前是否已绑定
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
const byteOffset = firstChangeIndex * 4;
gl.bufferSubData(gl.ARRAY_BUFFER, byteOffset, newData.subarray(firstChangeIndex));
}
};
但这在 React 中很难完美做到,因为 React 的渲染周期是异步且不确定的。我们不知道上一次 useEffect 执行完后,中间有没有其他地方调用了 gl.bindBuffer。
这就引出了更高级的技术:Buffer Mapping(缓冲区映射)。
第六章:终极奥义 —— gl.mapBufferRange (MapBufferRange)
如果你觉得 bufferSubData 还是太慢,因为每次都要把数据从 CPU 拷贝到显存,那你就需要了解 gl.mapBufferRange。
mapBufferRange 允许我们直接在 CPU 内存中修改 WebGL 缓冲区的内容,而不需要显式地调用 bufferSubData。这就像是直接拿到了 GPU 显存的一块物理内存地址,你改内存里的值,GPU 就会自动看到。
警告: 这是一个双刃剑。如果你修改了 GPU 正在使用的内存,画面就会崩坏。
代码示例 3:使用 MapBufferRange 进行零拷贝更新
const updateBufferWithMapping = (gl, buffer, oldData, newData) => {
// 1. 尝试映射缓冲区,只映射我们要修改的那一部分
// gl.UNSIGNED_SHORT_BIT | gl.UNSIGNED_INT_BIT 表示我们要写入的是整数类型
// gl.PERSISTENT_BIT | gl.WRITE_INVALIDATE_BIT 是关键
const pointer = gl.mapBufferRange(gl.ARRAY_BUFFER, 0, newData.byteLength,
gl.MAP_WRITE_BIT | gl.MAP_INVALIDATE_BUFFER_BIT | gl.MAP_UNSYNCHRONIZED_BIT);
if (!pointer) return; // 映射失败
// 2. 直接写入内存
// pointer 是一个 ArrayBufferView,可以直接赋值
const typedPointer = new Float32Array(pointer);
typedPointer.set(newData);
// 3. 必须调用 unmap 告诉 GPU 内存已经就绪
// 在 React 的 useEffect 中,我们通常不需要手动 unmap,因为 useEffect 结束时上下文会被清理
// 但为了保险,这里演示手动 unmap 的逻辑
gl.unmapBuffer(gl.ARRAY_BUFFER);
};
这里发生了什么?
gl.MAP_INVALIDATE_BUFFER_BIT 告诉 GPU:“你手里的旧数据我不要了,我直接覆盖你。”
gl.MAP_UNSYNCHRONIZED_BIT 告诉 GPU:“别管我,直接改,就算你正在用这块内存也没关系(虽然这很危险,但在 React 这种单线程环境下通常是安全的)。”
通过这种方式,我们将 CPU 到 GPU 的数据传输从“拷贝”变成了“赋值”,性能提升是惊人的。
第七章:封装的艺术 —— 抽象出 React 风格的 WebGL Buffer
既然手动管理 gl.bufferData、gl.bufferSubData 和 mapBufferRange 这么痛苦,而且容易出错,为什么不把它们封装起来,让 React 看起来像是在直接操作 Buffer 呢?
我们需要一个自定义的 Hook:useWebGLBuffer。
这个 Hook 应该具备以下能力:
- 接收 React 的
data(通常是一个Float32Array)。 - 监听
data的变化。 - 自动选择最佳更新策略(全量更新 vs 增量更新 vs MapBuffer)。
- 返回 WebGL 的 Buffer 对象和渲染函数。
代码示例 4:封装后的 Hook
import { useState, useEffect, useRef, useMemo } from 'react';
const useWebGLBuffer = (data, drawCallback) => {
const glRef = useRef(null);
const bufferRef = useRef(null);
const isMountedRef = useRef(false);
// 记录上一次的数据,用于 Diffing
const prevDataRef = useRef(null);
// 初始化 WebGL 资源
useEffect(() => {
const canvas = document.getElementById('glCanvas');
const gl = canvas.getContext('webgl');
if (!gl) return;
glRef.current = gl;
isMountedRef.current = true;
const buffer = gl.createBuffer();
bufferRef.current = buffer;
// 初始化:全量上传
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
return () => {
gl.deleteBuffer(buffer);
isMountedRef.current = false;
};
}, []);
// 处理数据更新
useEffect(() => {
if (!glRef.current || !bufferRef.current || !isMountedRef.current) return;
const gl = glRef.current;
const buffer = bufferRef.current;
const prevData = prevDataRef.current;
// 如果没有旧数据(初始化阶段),直接上传
if (!prevData) {
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, data, gl.DYNAMIC_DRAW);
prevDataRef.current = data;
return;
}
// 策略选择逻辑
const needsFullUpdate = prevData.length !== data.length;
if (needsFullUpdate) {
// 策略 A:长度变了,只能重置
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, data, gl.DYNAMIC_DRAW);
} else {
// 策略 B:长度没变,尝试增量更新
// 这里我们简化逻辑,直接使用 bufferSubData 全量替换(对于少量数据通常比遍历快)
// 或者实现上面的增量遍历逻辑
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferSubData(gl.ARRAY_BUFFER, 0, data);
}
prevDataRef.current = data;
}, [data]);
// 渲染函数
const render = () => {
if (!glRef.current) return;
const gl = glRef.current;
// 简单的渲染循环
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES, 0, data.length / 3); // 假设每个顶点是3个float
};
return { buffer: bufferRef.current, render };
};
// --- 使用示例 ---
const MyComponent = () => {
const [vertices, setVertices] = useState(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]));
const { render } = useWebGLBuffer(vertices);
// 偶尔更新数据
const randomize = () => {
const newData = new Float32Array(9);
for(let i=0; i<9; i++) newData[i] = Math.random();
setVertices(newData);
};
return (
<div>
<button onClick={randomize}>随机化顶点</button>
<canvas id="glCanvas" width={500} height={500} />
<button onClick={render}>手动重绘</button>
</div>
);
};
第八章:深入剖析 —— 为什么我们这么做?
通过上面的封装,我们成功地让 React 看起来像是直接控制了 WebGL Buffer。但实际上,我们在中间层做了一层非常复杂的“翻译”。
让我们回顾一下性能的时间线:
- React 渲染周期: 用户点击按钮 ->
setVertices-> React 重新渲染 ->vertices变量更新。耗时:极短(微秒级)。 - React Effect 触发:
useEffect检测到vertices依赖变化。耗时:极短。 - 数据 Diff: 我们比较了新旧数组。耗时:取决于数组长度。
- WebGL 命令执行:
gl.bindBuffer->gl.bufferSubData。耗时:取决于显存带宽。
关键点在于:React 的渲染周期通常比 WebGL 的数据传输要快得多。 如果我们在 React 里频繁地 setState,React 会帮我们做批处理。如果我们不把 React 的频繁更新“节流”或者“合并”,那么即使我们在 WebGL 层面做了优化,CPU 的负担也会很重。
所以,React 驱动的顶点更新,不仅仅是关于 WebGL 的 API 调用,更是关于数据流的管理。
第九章:高级话题 —— 多 Buffer 与 绑定状态管理
在实际的大型项目中,我们不仅仅有一个 Buffer。我们可能有位置 Buffer (POSITION)、颜色 Buffer (COLOR)、索引 Buffer (INDEX)。
当 React 更新颜色数据时,我们需要确保我们绑定的还是 COLOR_BUFFER,而不是 POSITION_BUFFER。如果绑错了,颜色数据就会写进位置缓冲区,导致图形扭曲。
解决方案:
不要在每次更新时都去 gl.bindBuffer。我们应该维护一个全局的“绑定状态机”。
// 简单的绑定状态机
const glState = {
boundBuffer: null,
boundProgram: null
};
const bindBufferSafely = (gl, target, buffer) => {
if (glState.boundBuffer !== buffer) {
gl.bindBuffer(target, buffer);
glState.boundBuffer = buffer;
}
};
在 React 的 useEffect 中,我们只需要调用这个安全的绑定函数。这能极大地减少 WebGL 的状态切换开销。
第十章:实战中的坑与调试
当你开始这样写代码时,你会发现几个非常坑爹的问题:
- 类型不匹配: React 传过来的是
Float32Array,你写gl.bufferData时传错了类型,或者 Shader 期望的是int但你传了float。结果:渲染一片黑。 - 生命周期错乱: 在
useEffect里设置了 Buffer,但组件卸载了。如果你再次挂载同一个组件,WebGL Context 可能会丢失或者报错。 - 内存泄漏: React 的
useRef里的数据如果不小心引用了外部对象,可能导致内存无法释放。
调试技巧:
不要相信你的眼睛。屏幕上显示的可能是旧的 Buffer 数据,因为 GPU 是异步渲染的。
使用 Chrome DevTools 的 WebGL Inspector 或者 RenderDoc。它们可以让你看到当前 Buffer 里到底存了什么数据。当你怀疑 React 的 setVertices 没生效时,用这个工具一抓,真相大白。
第十一章:未来的展望 —— React 19 与 WebGL
随着 React 19 的发布,它的并发模式和 Server Components 带来了更多的可能性。
未来的 React 可能会更好地处理“副作用”的时机。也许我们不需要再手动在 useEffect 里写 WebGL 的初始化逻辑了。也许我们会看到像 useFrame (来自 Three.js) 那样的概念被 React 原生支持,或者更激进一点,React 能直接识别 WebGL 的 Buffer 并提供类似 useMemo 的缓存机制。
但在此之前,掌握“如何将 React 状态转化为 WebGL 命令”依然是我们作为前端图形学工程师的必修课。
结语:保持敬畏,保持性能
好了,同学们,今天的讲座就到这里。
我们回顾了从最初的“同步地狱”到现在的“增量优化”的全过程。我们学会了使用 useRef 来隔离 React 的渲染周期,学会了使用 TypedArrays 来优化内存,学会了使用 bufferSubData 和 mapBufferRange 来实现高性能的更新。
记住,React 负责告诉“世界发生了什么变化”,而 WebGL 负责以最高的效率去执行这些变化。不要让 React 的“虚拟”特性拖累了 WebGL 的“实体”性能。
现在,拿起你的代码,去优化你的 Buffer 吧!别让你的 GPU 因为你的低效代码而哭泣。下课!
附录:完整示例代码 (混合了 React 和 WebGL 的最佳实践)
import React, { useState, useEffect, useRef } from 'react';
const AdvancedWebGLBuffer = () => {
const canvasRef = useRef(null);
const glRef = useRef(null);
const bufferRef = useRef(null);
// 维护一个引用,用于在 effect 之间传递旧数据
const prevDataRef = useRef(null);
// 维护一个引用,用于追踪 buffer 的绑定状态,避免无效的 bind 调用
const boundBufferRef = useRef(null);
const [vertices, setVertices] = useState(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]));
// 初始化 WebGL 上下文和 Buffer
useEffect(() => {
const canvas = canvasRef.current;
const gl = canvas.getContext('webgl', { preserveDrawingBuffer: true }); // preserveDrawingBuffer 允许截图
if (!gl) return;
glRef.current = gl;
const buffer = gl.createBuffer();
bufferRef.current = buffer;
// 初始上传
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
boundBufferRef.current = buffer;
return () => {
gl.deleteBuffer(buffer);
};
}, []);
// 核心更新逻辑
useEffect(() => {
if (!glRef.current || !bufferRef.current) return;
const gl = glRef.current;
const buffer = bufferRef.current;
const prevData = prevDataRef.current;
const currentData = vertices;
// 1. 检查是否需要重新创建 Buffer (长度变化)
if (prevData && prevData.length !== currentData.length) {
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, currentData, gl.DYNAMIC_DRAW);
} else {
// 2. 如果长度没变,尝试增量更新
// 简单实现:全量 bufferSubData。对于复杂场景,可以在这里实现 Diff 算法
// 只有当 buffer 确实被绑定了(或者是当前绑定的 buffer)时才 bind
if (boundBufferRef.current !== buffer) {
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
boundBufferRef.current = buffer;
}
gl.bufferSubData(gl.ARRAY_BUFFER, 0, currentData);
}
// 更新引用
prevDataRef.current = currentData;
// 3. 立即重绘
renderScene(gl, buffer);
}, [vertices]);
const renderScene = (gl, buffer) => {
if (!gl) return;
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
gl.clearColor(0.1, 0.1, 0.1, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
// 简单的着色器程序
// Vertex Shader
const vsSource = `
attribute vec4 aVertexPosition;
void main() {
gl_Position = aVertexPosition;
}
`;
// Fragment Shader
const fsSource = `
void main() {
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}
`;
// 编译 Shader (为了演示简单,这里省略了复杂的 shader 编译逻辑,实际项目应缓存 shader program)
const shaderProgram = initShaderProgram(gl, vsSource, fsSource);
const programInfo = {
program: shaderProgram,
attribLocations: {
vertexPosition: gl.getAttribLocation(shaderProgram, 'aVertexPosition'),
},
};
gl.useProgram(programInfo.program);
// 绑定 Buffer
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.vertexAttribPointer(
programInfo.attribLocations.vertexPosition,
3, // numComponents (x, y, z)
gl.FLOAT,
false,
0,
0
);
gl.enableVertexAttribArray(programInfo.attribLocations.vertexPosition);
gl.drawArrays(gl.TRIANGLES, 0, 3);
};
// 辅助函数:编译 Shader (为了代码完整性)
const initShaderProgram = (gl, vsSource, fsSource) => {
// ... 省略 ...
return null;
};
const handleClick = () => {
// 模拟一个简单的动画效果:旋转三角形
const newData = new Float32Array([
Math.random(), Math.random(), Math.random(), // x, y, z
Math.random(), Math.random(), Math.random(),
Math.random(), Math.random(), Math.random()
]);
setVertices(newData);
};
return (
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
<h2>React 驱动的 WebGL Buffer 增量更新</h2>
<div style={{ display: 'flex', gap: '20px' }}>
<canvas ref={canvasRef} width={500} height={500} style={{ border: '1px solid #ccc' }} />
<div>
<button onClick={handleClick} style={{ padding: '10px 20px', cursor: 'pointer' }}>
随机更新顶点
</button>
<p>观察控制台或 Canvas,你应该能看到顶点实时变化。</p>
<p>注意:这种更新方式避免了不必要的 gl.bufferData 调用。</p>
</div>
</div>
</div>
);
};
export default AdvancedWebGLBuffer;