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 Marchingbr/逐步采样] 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 上可维持 60fps128^3 纹理但提升到 512 步后帧率可能降至 30fps 以下。解决方案是自适应步长对密度高的区域增加采样步数稀疏区域跳跃采样。空域跳跃Empty Space Skipping可以用一张低分辨率的占位网格预先标记空区域。体素分辨率的 GPU 内存限制。128^3 的 uint8 纹理占用 2MB VRAM256^3 占 16MB512^3 占 128MB。在移动端 GPU 上超过 256^3 可能导致内存不足。渲染质量不依赖更高分辨率——体素的视觉贡献取决于密度分布而非分辨率128-256 范围对大多数链上数据集足够。实时数据更新的瓶颈在数据传输。WebGL 的 texImage3D 调用耗时与纹理大小成正比256^3 的更新约需 10-20ms。如果链上数据以每秒多个区块的速度更新需要用 PBOPixel Buffer Object做异步上传或者降低更新频率为每 5-10 秒一次。多渲染目标的并行。体积渲染的 fragment shader 计算密集同时渲染其他 Three.js 场景对象会进一步拖低帧率。建议将数据可视化作为独立 canvas 层使用 OffscreenCanvas Worker 线程避免与 UI 层竞争 GPU 资源。传递函数的选择对可读性至关重要。简单的线性映射密度 → 颜色 → 透明度在数据分布不均匀时会产生热点区域过曝、冷区全透明的问题。建议用对数映射 手动定义颜色停靠点对密度 10% 分位数的体素设为几乎透明alpha0.05对 90% 分位数的体素设高不透明度alpha0.9中间段用曲线插值。这样低密度区域不会被完全隐藏高密度区域不会遮挡内部结构。五、总结体积渲染为链上数据可视化提供了一个有价值的补充维度。当二维图表的密度到达信息上限时第三维不是装饰——它是承载地址间关系、价格时间分布等真实数据维度的有效载体。Three.js 的 WebGL 后端 手写 Ray Marching shader 是当前兼容性最好的方案。在工程实践中核心挑战不是实现体积渲染本身逻辑相对标准的 Ray Marching 传递函数而是数据映射——将非结构化的链上事件转化为有意义的体素密度分布。这一层的设计质量直接决定了可视化是否能传递 insight而非仅仅看起来炫酷。