Three.js 体积渲染在链上数据可视化中的应用:体素化与 Ray Marching 实践
Three.js 体积渲染在链上数据可视化中的应用:体素化与 Ray Marching 实践
一、链上数据的可视化困境:平面图表的信息密度天花板
区块链数据天然是三维的——时间(区块高度)、空间(地址间交互)、价值(交易金额)构成三个互相正交的维度。但绝大多数 DApp 仪表盘仍然停留在二维图表:折线图显示 TVL 变化、柱状图对比 DEX 交易量、热力图展示 Gas 分布。
当需要展示地址簇的交互关系、多层 DeFi 的资金流向、MEV 套利的三明治攻击时序时,二维可视化开始丢失信息。Scatter Plot 的第三维被映射为颜色或点大小,但人脑对空间深度的感知远优于对色谱的分辨。
更具体地说,考虑一个套利机器人跨 5 个 DEX 执行原子交易的场景:每笔交易的时间间隔在毫秒级,资金在 5 个合约之间流动形成一个闭合环路。在二维散点图中,你只能看到 5 个点按时间排序连线——信息全部压缩在平面上。而在三维体积渲染中,X 轴是合约地址、Y 轴是时间(区块高度)、Z 轴是金额,用户旋转视角就能在不同维度上解构这条套利环路。体积渲染提供了一种在不同维度上探索链上数据的方式。它不是取代二维图表的实用性,而是在需要展示"集合中个体的空间分布与密度"时提供补充视角。
二、体素化与 Ray Marching 的渲染原理
flowchart LR subgraph 数据预处理 RD[链上原始数据<br/>交易/地址/金额] --> NORM[归一化<br/>映射到 0-1 空间] NORM --> VOX[体素化<br/>128x128x128 网格] end subgraph 渲染管线 VOX -->|3D纹理| GPU[GPU Shader] GPU --> RM[Ray Marching<br/>逐步采样] RM --> ACC[累积颜色与透明度] ACC --> FRAMEBUFFER[帧缓冲] end subgraph 交互层 FRAMEBUFFER --> CAM[轨道相机控制] CAM --> SLICER[截面切割<br/>展示内部结构] SLICER --> DISPLAY[WebGL Canvas] end VOX -->|密度值| COLOR[传递函数<br/>密度→颜色映射] COLOR --> ACC体素化是将连续空间离散化为规则网格的过程。链上数据映射到三维空间后,每个体素存储该区域内的"事件密度"——可能是在该价格区间和区块范围交易的地址数量,或者某对代币在不同时间点的流动性状态。
Ray Marching 是体积渲染的核心算法。从相机向每个像素发射一条射线,沿射线以固定步长前进,每一步在体素网格中采样颜色和透明度(alpha),按"从后到前"或"从前到后"的顺序混合。关键优势是:不同于表面渲染需要三角形网格,Ray Marching 直接对体数据采样,天然适合展示内部结构。
三、Three.js WebGL 体积渲染实现
// volume/VolumeRenderer.ts import * as THREE from 'three'; /** * 链上数据体积渲染器 * 设计决策: * 1. 使用 THREE.js WebGL 后端,不依赖 WebGPU(兼容性优先) * 2. 体素数据存储在 DataTexture 中,单通道 8-bit 密度 * 3. Ray Marching 在 Fragment Shader 中完全实现,CPU 仅负责数据上传 */ export class ChainVolumeRenderer { private scene: THREE.Scene; private camera: THREE.PerspectiveCamera; private renderer: THREE.WebGLRenderer; private volumeMaterial: THREE.ShaderMaterial; private volumeData: Uint8Array; private gridSize: number = 128; constructor(canvas: HTMLCanvasElement) { this.scene = new THREE.Scene(); this.camera = new THREE.PerspectiveCamera( 60, canvas.clientWidth / canvas.clientHeight, 0.1, 10 ); this.camera.position.set(1.5, 1.5, 1.5); this.camera.lookAt(0.5, 0.5, 0.5); this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); this.renderer.setSize(canvas.clientWidth, canvas.clientHeight); // 初始化空数据 this.volumeData = new Uint8Array(this.gridSize ** 3); // 创建渲染体(单位立方体) const geometry = new THREE.BoxGeometry(1, 1, 1); this.volumeMaterial = this.createVolumeMaterial(); const mesh = new THREE.Mesh(geometry, this.volumeMaterial); this.scene.add(mesh); // 轨道控制 this.setupOrbitControls(canvas); } /** * 更新体素数据 * @param data 原始数组(gridSize^3 个 uint8) */ updateVolumeData(data: Uint8Array) { this.volumeData = data; // 创建 3D 纹理 const texture = new THREE.Data3DTexture( data, this.gridSize, this.gridSize, this.gridSize ); texture.format = THREE.RedFormat; texture.type = THREE.UnsignedByteType; texture.needsUpdate = true; this.volumeMaterial.uniforms.volumeMap.value = texture; } /** * 创建体积渲染 Shader * Ray Marching 在 fragment shader 中执行 */ private createVolumeMaterial(): THREE.ShaderMaterial { return new THREE.ShaderMaterial({ uniforms: { volumeMap: { value: null }, // 传递函数:低密度→蓝色(低活跃区),高密度→橙红(高活跃区) steps: { value: 256 }, // Ray 采样步数,越多越精确但越慢 alphaScale: { value: 1.0 }, // 透明度缩放 cameraPos: { value: new THREE.Vector3() }, }, vertexShader: /* glsl */ ` varying vec3 vWorldPos; varying vec3 vLocalPos; void main() { vec4 worldPos = modelMatrix * vec4(position, 1.0); vWorldPos = worldPos.xyz; vLocalPos = position; // 单位立方体 [-0.5, 0.5] gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `, fragmentShader: /* glsl */ ` varying vec3 vWorldPos; varying vec3 vLocalPos; uniform sampler3D volumeMap; uniform int steps; uniform float alphaScale; uniform vec3 cameraPos; /** * 密度值到颜色的传递函数 * 低密度(交易少) -> 蓝色系 * 中密度 -> 品红 * 高密度(交易密集) -> 橙红 */ vec3 densityToColor(float density) { vec3 lowColor = vec3(0.1, 0.2, 0.8); // 蓝色 vec3 midColor = vec3(0.8, 0.2, 0.6); // 品红 vec3 highColor = vec3(1.0, 0.4, 0.1); // 橙红 if (density < 0.5) { return mix(lowColor, midColor, density * 2.0); } else { return mix(midColor, highColor, (density - 0.5) * 2.0); } } void main() { // 射线方向:从相机指向当前片元的世界位置 vec3 rayOrigin = cameraPos; vec3 rayDir = normalize(vWorldPos - cameraPos); // 计算射线与立方体的交点 vec3 boxMin = vec3(-0.5); vec3 boxMax = vec3(0.5); vec3 tMin = (boxMin - rayOrigin) / rayDir; vec3 tMax = (boxMax - rayOrigin) / rayDir; vec3 t1 = min(tMin, tMax); vec3 t2 = max(tMin, tMax); float tNear = max(max(t1.x, t1.y), t1.z); float tFar = min(min(t2.x, t2.y), t2.z); // Ray 不与立方体相交 if (tNear > tFar || tFar < 0.0) { discard; } tNear = max(tNear, 0.0); // Ray Marching float stepSize = (tFar - tNear) / float(steps); vec3 step = rayDir * stepSize; vec3 pos = rayOrigin + rayDir * tNear; vec4 accumulatedColor = vec4(0.0); for (int i = 0; i < 256; i++) { if (i >= steps) break; // 将世界坐标映射到纹理坐标 [0, 1] vec3 texCoord = pos + 0.5; // 边界检查 if (any(lessThan(texCoord, vec3(0.0))) || any(greaterThan(texCoord, vec3(1.0)))) { break; } // 采样体素密度 float density = texture(volumeMap, texCoord).r; if (density > 0.01) { vec3 sampleColor = densityToColor(density); float alpha = density * alphaScale * stepSize * 30.0; // 从前到后混合:accumulated += sample * alpha * (1 - accumulated.a) accumulatedColor.rgb += sampleColor * alpha * (1.0 - accumulatedColor.a); accumulatedColor.a += alpha * (1.0 - accumulatedColor.a); } // 提前退出:透明度接近饱和 if (accumulatedColor.a > 0.98) break; pos += step; } gl_FragColor = accumulatedColor; // 没有可见内容的区域透明化 if (gl_FragColor.a < 0.01) discard; } `, transparent: true, depthWrite: false, // 体积渲染禁用深度写入,保证半透明混合 }); } private setupOrbitControls(canvas: HTMLCanvasElement) { let isDragging = false; let prevX = 0; let prevY = 0; let spherical = { theta: Math.PI / 4, phi: Math.PI / 3, radius: 2.5 }; canvas.addEventListener('mousedown', (e) => { isDragging = true; prevX = e.clientX; prevY = e.clientY; }); canvas.addEventListener('mousemove', (e) => { if (!isDragging) return; const dx = (e.clientX - prevX) * 0.005; const dy = (e.clientY - prevY) * 0.005; spherical.theta -= dx; spherical.phi = Math.max(0.1, Math.min(Math.PI - 0.1, spherical.phi + dy)); prevX = e.clientX; prevY = e.clientY; this.updateCamera(spherical); }); canvas.addEventListener('mouseup', () => { isDragging = false; }); canvas.addEventListener('wheel', (e) => { spherical.radius = Math.max(1.0, Math.min(5.0, spherical.radius + e.deltaY * 0.005)); this.updateCamera(spherical); }); this.updateCamera(spherical); } private updateCamera( spherical: { theta: number; phi: number; radius: number } ) { this.camera.position.set( spherical.radius * Math.sin(spherical.phi) * Math.cos(spherical.theta), spherical.radius * Math.cos(spherical.phi), spherical.radius * Math.sin(spherical.phi) * Math.sin(spherical.theta) ); this.camera.lookAt(0, 0, 0); this.volumeMaterial.uniforms.cameraPos.value.copy(this.camera.position); } render() { this.renderer.render(this.scene, this.camera); requestAnimationFrame(() => this.render()); } }数据从链上事件映射到体素网格:
// volume/data-loader.ts /** * 将 DeFi 交易数据映射为体素密度 * 三维:X=代币对(按地址排序), Y=价格区间, Z=时间(区块) */ export function transactionsToVolume( transactions: Array<{ tokenPair: string; price: number; blockNumber: number; volume: number; }>, gridSize: number = 128 ): Uint8Array { const volume = new Float32Array(gridSize ** 3); const maxDensity = new Float32Array(gridSize ** 3); // 构建离散化映射 const sortedPairs = [...new Set(transactions.map(t => t.tokenPair))].sort(); const pairMap = new Map(sortedPairs.map((p, i) => [p, i])); const prices = transactions.map(t => t.price); const priceMin = Math.min(...prices); const priceMax = Math.max(...prices); const blocks = transactions.map(t => t.blockNumber); const blockMin = Math.min(...blocks); const blockMax = Math.max(...blocks); // 归一化并累积 for (const tx of transactions) { const x = Math.floor((pairMap.get(tx.tokenPair)! / sortedPairs.length) * (gridSize - 1)); const y = Math.floor( ((tx.price - priceMin) / Math.max(priceMax - priceMin, 1)) * (gridSize - 1) ); const z = Math.floor( ((tx.blockNumber - blockMin) / Math.max(blockMax - blockMin, 1)) * (gridSize - 1) ); const idx = x + y * gridSize + z * gridSize * gridSize; volume[idx] += tx.volume; maxDensity[idx] = Math.max(maxDensity[idx], tx.volume); } // 归一化到 0-255 const maxVal = Math.max(...volume, 1); const result = new Uint8Array(gridSize ** 3); for (let i = 0; i < result.length; i++) { result[i] = Math.floor((volume[i] / maxVal) * 255); } return result; }四、Ray Marching 的性能边界
采样步数与帧率的 trade-off。256 步的 Ray Marching 在中端 GPU 上可维持 60fps(128^3 纹理),但提升到 512 步后帧率可能降至 30fps 以下。解决方案是自适应步长:对密度高的区域增加采样步数,稀疏区域跳跃采样。空域跳跃(Empty Space Skipping)可以用一张低分辨率的"占位网格"预先标记空区域。
体素分辨率的 GPU 内存限制。128^3 的 uint8 纹理占用 2MB VRAM,256^3 占 16MB,512^3 占 128MB。在移动端 GPU 上,超过 256^3 可能导致内存不足。渲染质量不依赖更高分辨率——体素的视觉贡献取决于密度分布而非分辨率,128-256 范围对大多数链上数据集足够。
实时数据更新的瓶颈在数据传输。WebGL 的 texImage3D 调用耗时与纹理大小成正比,256^3 的更新约需 10-20ms。如果链上数据以每秒多个区块的速度更新,需要用 PBO(Pixel Buffer Object)做异步上传,或者降低更新频率为每 5-10 秒一次。
多渲染目标的并行。体积渲染的 fragment shader 计算密集,同时渲染其他 Three.js 场景对象会进一步拖低帧率。建议将数据可视化作为独立 canvas 层,使用 OffscreenCanvas + Worker 线程,避免与 UI 层竞争 GPU 资源。
传递函数的选择对可读性至关重要。简单的线性映射(密度 → 颜色 → 透明度)在数据分布不均匀时会产生"热点区域过曝、冷区全透明"的问题。建议用对数映射 + 手动定义颜色停靠点:对密度 < 10% 分位数的体素设为几乎透明(alpha=0.05),对 > 90% 分位数的体素设高不透明度(alpha=0.9),中间段用曲线插值。这样低密度区域不会被完全隐藏,高密度区域不会遮挡内部结构。
五、总结
体积渲染为链上数据可视化提供了一个有价值的补充维度。当二维图表的密度到达信息上限时,第三维不是装饰——它是承载地址间关系、价格时间分布等真实数据维度的有效载体。
Three.js 的 WebGL 后端 + 手写 Ray Marching shader 是当前兼容性最好的方案。在工程实践中,核心挑战不是实现体积渲染本身(逻辑相对标准的 Ray Marching + 传递函数),而是数据映射——将非结构化的链上事件转化为有意义的体素密度分布。这一层的设计质量直接决定了可视化是否能传递 insight,而非仅仅"看起来炫酷"。