最近我在折腾一个很有意思的交互视觉项目用 React 和 Three.js 把一枚 Apple logo 做成热成像效果。所谓热成像就是模拟红外热像仪的画面把物体表面“温度”映射成一条黑-蓝-青-绿-黄-红-白的伪彩色带看起来就像这枚 logo 刚刚被红外设备扫过、还在持续散热。这个想法特别适合做作品集首页、直播间动态背景、发布会暖场动效或者单纯给自己找点 WebGL 手感。这篇内容不是纯 API 罗列我会按真实开发顺序走一遍先拆解热成像视觉原理和方案选型再把 SVG 路径挤出成 3D 模型接着写一个真正能跑的温度着色器最后落在 React 生命周期管理和踩坑排查上。React 开发者可以把它当 Three.js 入门实战Three.js 老手也能看看怎么把场景干净地包进 React 组件。1. 项目拆解热成像 Apple Logo 都有哪些技术模块1.1 热成像的视觉规则先搞清楚热成像本质上是一个“标量场可视化”问题。真实世界的红外热像仪捕捉的是物体表面辐射出的红外能量经过传感器转换后每个像素对应一个温度值。为了让肉眼读懂温度分布设备会把温度值映射到一条固定的伪彩色带上常见的有 White Hot、Ironblack、Rainbow 等调色板。在 WebGL 里复刻这个效果数学上非常简单每个顶点或像素需要一个 0 到 1 之间的温度值 t然后通过一段颜色映射函数把 t 变成长度为 3 的 RGB。关键是不要直接拿 RGB 三通道去乘噪声那样很容易调出廉价彩虹糖质感。正确做法是让 t 经过一条精心设计的颜色曲线就像在颜色条上滑动一样这样得到的高温白、中温黄红、低温蓝紫才会符合人对热红外画面的直觉。以 Apple logo 为例我们想实现的效果是logo 自身有一个基础温度场高温区域集中在内部偏上位置边缘因为散热慢慢变冷同时会有类似热成像仪的扫描带从左到右扫过扫描到的地方温度瞬时抬升、呈现高亮。整体看起来像科幻设备界面里的一枚立体徽章。1.2 为什么是 React Three.js而不是纯 CSS 或 Canvas 2D有人会问用 CSS 滤镜加几个动画层不也能模拟热成像吗确实可以做个平面伪色版但你要实现立体的 logo 厚度、自由拖拽旋转、动态扫描和镜头泛光时CSS 方案就会非常吃力而且很难做出热成像那种“表面温度分布”的连续渲染。Canvas 2D 也能做平面图案的像素级映射但如果你想让 logo 有真正的物理厚度或者从侧面看也有温度变化Canvas 2D 就得自己实现所有三维变换和光照模型性价比很低。Three.js 的优势是它把 WebGL 里最常用的东西都封装好了ExtrudeGeometry 可以把 SVG 路径挤出成 3D 几何体ShaderMaterial 可以精确控制每一个像素的最终颜色EffectComposer 加 UnrealBloomPass 能模拟热成像传感器的高光溢出现象。React 在这里不是替代 Three.js而是管理 Three.js 的生命周期和 UI 状态。简单说Three.js 负责 GPU 渲染React 负责组件挂载、卸载和交互数据传递。1.3 最终效果与依赖清单我先描述一下最终跑起来的效果一个近黑色背景一枚带圆润倒角的立体 Apple logo 悬浮在画面中央logo 表面覆盖一层动态温度场噪声让颜色像红外画面一样微微颤动。每隔几秒一道扫描带从左到右扫过扫过区域瞬间变亮变热配合 Bloom 泛光看起来确实是“设备正在扫描”。技术栈建议如下React 18 Vite开发体验好热更新快构建产物也干净。Three.js0.150 以上核心渲染库。OrbitControls方便拖拽旋转观察。three/examples/jsm/postprocessing做 Bloom 后处理。可选 react-three/fiber 和 react-three/drei如果你希望用更声明式的方式写 Three.js可以选这条路线但下面为了讲清原理我会用原生 Three.js 配合 React 自定义 Hook这样也方便大家直接迁移到 Vue 或其他框架。2. 核心细节从图标路径到热辐射着色器2.1 拿到 Apple logo 的轮廓并挤出成 3D 模型做立体 logo 的第一步是拿到轮廓数据。最常见的方法是准备一份 SVG 文件读取其中的 path 元素的 d 属性。Apple logo 的 SVG path 通常包含两个子路径一个是果身一个是叶子。少数版本还会包含被咬掉部分的轮廓。对 Three.js 来说最直接的工具是 ShapePath 和 ExtrudeGeometry。import { ShapePath, Shape, ExtrudeGeometry } from three; const svgPath M...; // 从 SVG 里复制出来的一整段 path data const shapePath new ShapePath(); shapePath.fromSVGPathData(svgPath); const shapes shapePath.toShapes(true); const geometry new ExtrudeGeometry(shapes, { depth: 0.6, bevelEnabled: true, bevelThickness: 0.2, bevelSize: 0.1, bevelSegments: 3, }); geometry.center();这里有两个容易踩的细节。第一toShapes(true)的作用是把 SVG 中互相独立的子路径拆成多个 Shape 对象因为 Apple logo 的果身和叶子可能不是同一个连续路径。如果你只取toShapes()[0]可能只有果身叶子会丢。第二ExtrudeGeometry是支持 Shape 数组的所以直接传shapes即可不要手动合并成一个 Shape。还有些 SVG 路径会包含transform矩阵、m相对命令或A圆弧命令。ShapePath.fromSVGPathData对大多数M/L/C/Q/Z命令支持良好但圆弧命令偶尔会出现兼容问题。解决办法是回到矢量编辑器里把路径转成纯三次贝塞尔曲线再导出或者找一份已经简化的开源 Apple logo 路径资源。2.2 热成像着色器温度场与颜色映射3D 模型有了之后接下来的重头戏是着色器。我们不使用 MeshStandardMaterial 那种依赖光照的材质而是直接用 ShaderMaterial自己控制每个像素的颜色。顶点着色器很简单把 uv 和模型空间坐标传给片元。varying vec2 vUv; varying vec3 vModelPos; void main() { vUv uv; vModelPos position; gl_Position projectionMatrix * modelViewMatrix * vec4(position, 1.0); }片元着色器里我先准备一个“伪彩色映射函数”。温度从 0 到 1分别经过深蓝、蓝、青、绿、黄、橙红、白这几个控制点。这样比线性插值两个颜色更接近真实热像仪。uniform sampler2D uNoiseTex; uniform float uTime; uniform float uScan; varying vec2 vUv; varying vec3 vModelPos; float hash(vec2 p) { return fract(sin(dot(p.xy, vec2(12.9898, 78.233))) * 43758.5453); } vec3 thermalPalette(float t) { vec3 c0 vec3(0.02, 0.02, 0.08); vec3 c1 vec3(0.05, 0.15, 0.70); vec3 c2 vec3(0.10, 0.75, 0.80); vec3 c3 vec3(0.20, 0.85, 0.20); vec3 c4 vec3(1.00, 0.85, 0.10); vec3 c5 vec3(1.00, 0.30, 0.05); vec3 c6 vec3(1.00, 0.95, 0.85); t clamp(t, 0.0, 1.0); if (t 0.15) return mix(c0, c1, smoothstep(0.0, 0.15, t)); if (t 0.30) return mix(c1, c2, smoothstep(0.15, 0.30, t)); if (t 0.45) return mix(c2, c3, smoothstep(0.30, 0.45, t)); if (t 0.60) return mix(c3, c4, smoothstep(0.45, 0.60, t)); if (t 0.80) return mix(c4, c5, smoothstep(0.60, 0.80, t)); return mix(c5, c6, smoothstep(0.80, 1.0, t)); } void main() { vec2 uv vUv; // 两层不同频率的噪声叠加让温度场既连续又有细节 float n1 texture2D(uNoiseTex, uv * 2.0 uTime * 0.03).r; float n2 texture2D(uNoiseTex, uv * 6.0 - uTime * 0.02).r; float temp 0.35 n1 * 0.45 n2 * 0.20; // 扫描带效果uScan 在 0-1 之间循环 float scanDist abs(uv.x - uScan); float scan smoothstep(0.06, 0.0, scanDist); temp scan * 0.40; vec3 color thermalPalette(temp); // 热噪抖动模拟红外传感器的微小扰动 float dither hash(uv * 120.0 fract(uTime * 0.1) * 40.0) * 0.04; color dither * 0.05; gl_FragColor vec4(color, 1.0); }噪声贴图我建议用 Canvas 生成一张 Perlin 或 Simplex 噪声纹理用 DataTexture 传入这样比在片段着色器里反复计算噪声更高效。const size 128; const data new Uint8Array(size * size * 4); // 填充数据这里可以放入自己写的噪声函数 const texture new THREE.DataTexture(data, size, size, THREE.RGBAFormat); texture.wrapS THREE.RepeatWrapping; texture.wrapT THREE.RepeatWrapping;扫描带这里用的是uv.x所以扫描线会贴着模型的 UV 方向移动。对于苹果 logo 这种以正面为主的挤压体正面效果已经足够自然。如果你希望扫描线是屏幕方向的需要在顶点着色器里把 NDC 坐标传到片元再用smoothstep(abs(ndc.x - scan))来做。2.3 React 怎么管理 Three.js 的生命周期在 React 里使用 Three.js最大的坑不是写 shader而是管理场景的创建和销毁。React 组件可能因为状态更新被频繁渲染而 Three.js 场景通常只需要创建一次。我习惯把整个 Three.js 世界藏在useRef里只暴露少量状态给 React。const mountRef useRef(null); useEffect(() { const mount mountRef.current; if (!mount) return; // 1. 创建渲染器、场景、相机 const renderer new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(mount.clientWidth, mount.clientHeight); mount.appendChild(renderer.domElement); const scene new THREE.Scene(); scene.background new THREE.Color(0x040404); const camera new THREE.PerspectiveCamera(60, mount.clientWidth / mount.clientHeight, 0.1, 100); camera.position.set(0, 0, 7); // 2. 创建几何体、材质、网格 // ... // 3. 动画循环 const clock new THREE.Clock(); renderer.setAnimationLoop(() { uniforms.uTime.value clock.getElapsedTime(); uniforms.uScan.value (clock.getElapsedTime() % 3) / 3; controls.update(); composer.render(); }); // 4. 窗口缩放监听 const handleResize () { camera.aspect mount.clientWidth / mount.clientHeight; camera.updateProjectionMatrix(); renderer.setSize(mount.clientWidth, mount.clientHeight); composer.setSize(mount.clientWidth, mount.clientHeight); }; window.addEventListener(resize, handleResize); // 5. 清理函数 return () { renderer.setAnimationLoop(null); window.removeEventListener(resize, handleResize); controls.dispose(); renderer.dispose(); if (renderer.domElement.parentNode) { renderer.domElement.parentNode.removeChild(renderer.domElement); } }; }, []);这段代码里最关键的是清理函数。React 18 的 StrictMode 会让 effect 执行两次第一次创建场景cleanup 销毁第二次再创建。如果你的 cleanup 没把 canvas 从 DOM 上摘掉页面上会同时出现两个 WebGL canvas而且后面的动画循环可能还在跑内存很快会涨上去。我自己一开始偷懒没做setAnimationLoop(null)结果切页后 GPU 占用一直居高不下。3. 实操实录完整代码跑通热成像 Logo3.1 初始化工程和安装依赖为了不绕弯路直接用 Vite 创建 React 项目。npm create vitelatest react-thermal-logo -- --template react cd react-thermal-logo npm install npm install three如果是 TypeScript 项目再装类型声明npm install -D types/three目录结构建议这样组织src/ App.jsx components/ ThermalLogo.jsx three/ createThermalLogo.js thermal.frag thermal.vert把 Scene 的创建逻辑尽量和组件分开。组件只负责初始化、销毁和挂载而具体创建场景的函数可以独立测试。3.2 创建 ThermalLogo 组件我会把直接能用的组件代码放在这里。注意OrbitControls的引入路径Three.js 新版本在three/examples/jsm/controls/OrbitControls.js。import { useEffect, useRef } from react; import * as THREE from three; import { OrbitControls } from three/examples/jsm/controls/OrbitControls.js; import { EffectComposer } from three/examples/jsm/postprocessing/EffectComposer.js; import { RenderPass } from three/examples/jsm/postprocessing/RenderPass.js; import { UnrealBloomPass } from three/examples/jsm/postprocessing/UnrealBloomPass.js; function ThermalLogo() { const mountRef useRef(null); useEffect(() { const mount mountRef.current; if (!mount) return; // 场景与相机 const scene new THREE.Scene(); scene.background new THREE.Color(0x040404); const camera new THREE.PerspectiveCamera(60, mount.clientWidth / mount.clientHeight, 0.1, 100); camera.position.set(0, 0, 7); // 渲染器 const renderer new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(mount.clientWidth, mount.clientHeight); mount.appendChild(renderer.domElement); // 轨道控制 const controls new OrbitControls(camera, renderer.domElement); controls.enableDamping true; controls.dampingFactor 0.08; controls.autoRotate true; controls.autoRotateSpeed 0.5; // 构造 geometry 和 ShaderMaterial const path M...; // 你的 Apple logo 路径 const shapePath new THREE.ShapePath(); shapePath.fromSVGPathData(path); const shapes shapePath.toShapes(true); const geometry new THREE.ExtrudeGeometry(shapes, { depth: 0.6, bevelEnabled: true, bevelThickness: 0.2, bevelSize: 0.1, bevelSegments: 3, }); geometry.center(); // 创建噪声纹理 const noiseTexture createNoiseTexture(); const uniforms { uNoiseTex: { value: noiseTexture }, uTime: { value: 0 }, uScan: { value: 0 }, }; const material new THREE.ShaderMaterial({ uniforms, vertexShader: vertexShaderString, fragmentShader: fragmentShaderString, side: THREE.DoubleSide, }); const mesh new THREE.Mesh(geometry, material); mesh.scale.set(1.2, 1.2, 1.2); scene.add(mesh); // 后处理 const composer new EffectComposer(renderer); composer.addPass(new RenderPass(scene, camera)); const bloomPass new UnrealBloomPass( new THREE.Vector2(mount.clientWidth, mount.clientHeight), 0.8, 0.3, 0.1 ); composer.addPass(bloomPass); // 动画循环 const clock new THREE.Clock(); renderer.setAnimationLoop(() { const t clock.getElapsedTime(); uniforms.uTime.value t; uniforms.uScan.value (t % 4) / 4; controls.update(); composer.render(); }); const handleResize () { camera.aspect mount.clientWidth / mount.clientHeight; camera.updateProjectionMatrix(); renderer.setSize(mount.clientWidth, mount.clientHeight); composer.setSize(mount.clientWidth, mount.clientHeight); }; window.addEventListener(resize, handleResize); return () { renderer.setAnimationLoop(null); window.removeEventListener(resize, handleResize); controls.dispose(); geometry.dispose(); material.dispose(); noiseTexture.dispose(); renderer.dispose(); if (renderer.domElement.parentNode) { renderer.domElement.parentNode.removeChild(renderer.domElement); } }; }, []); return ( div ref{mountRef} style{{ width: 100%, height: 600px, borderRadius: 16, overflow: hidden }} / ); }这个组件里我没有写完整的 vertexShader 和 fragmentShader因为比较长实际开发中建议拆成单独的文件或者模板字符串。核心逻辑已经在第 2 章解释过可以直接使用。3.3 加入 Bloom 后处理让热源真正“亮”起来热成像仪画面里高温区域的边缘会有一种强烈的光晕渗透感这来自感光元件过曝和镜头内部的漫反射。Three.js 里最方便模拟这种效果的就是 UnrealBloomPass。它的三个参数分别是 strength、radius 和 threshold。我的实测经验是strength 控制泛光强度0.6 到 0.9 之间比较合适太高画面全是糊的。radius 控制在 0.2 到 0.5数值太大会把冷色区域也带出辉光。threshold 建议 0.1 以下因为我们希望亮色区域从比较低的亮度就开始泛光。如果你觉得 Bloom 太重也可以在 ShaderMaterial 里直接用 AdditiveBlending给模型叠加一个“发热层”这样移动端性能会更好毕竟后处理是全局的像素操作开销不小。3.4 让 logo 有立体感的辅助细节热成像的模型不能是一张纸片看着会露怯。挤出几何体之后侧面在默认 UV 下是独立展开的这可能导致侧面温度分布和正面不一致。我的处理方式是让着色器优先使用模型空间坐标作为温度采样坐标而不是 UV。顶点着色器里把position.xy传给片元varying vec2 vModelXY; void main() { vModelXY position.xy; // ... }片元着色器里vec2 tempUV vModelXY * 1.5 0.5; float n1 texture2D(uNoiseTex, tempUV * 2.0 uTime * 0.03).r; float n2 texture2D(uNoiseTex, tempUV * 6.0 - uTime * 0.02).r;这样正面和侧面的温度场是连续过渡的不会出现正面像烫伤、侧面却是冰蓝的割裂感。3.5 运行效果与参数调优实际跑起来后你会发现结果对几个参数非常敏感。我把调节经验整理成一张表参数作用推荐值说明baseTemp基础温度基准0.30.45太低整体偏黑太高饱和度过重noiseScale温度波动幅度0.30.5越大温度差异越明显scanDuration一次扫描周期35 秒太短像触电太长让人等得着急bloomStrength泛光强度0.61.0超过 1.2 会掩盖 logo 轮廓cameraZ相机距离68配合等比缩放保证 logo 占画面 40%4. 避坑指南常见问题与排查方法4.1 SVG 路径转换失败是个高频雷许多人在 running 前卡在路径解析。常见报错是fromSVGPathData返回空数组或者运行时直接抛异常。我排查后总结了几种情况路径中含有transformmatrix(...)导致解析出来的坐标坐标范围异常。需要先在矢量编辑器里“平展变换”或者手动将 transform 应用到坐标点。路径中含有A圆弧命令。Three.js 的 ShapePath 对标准 SVG 曲线支持不错但对复杂圆弧路径支持有限。最简单的对策是找一份把贝塞尔曲线全部转成C命令的 Apple logo 路径资源。toShapes(true)返回多个 Shape但如果子路径之间存在包含关系比如“咬掉的部分”作为内轮廓存在Three.js 可能无法自动判断布尔关系。这种情况建议只做果身和叶子两个轮廓不要追求过于复杂的 SVG。调试路径时可以先把生成好的 Shape 用 ShapeGeometry 画出来配上简单的 MeshBasicMaterial 快速确认轮廓是否正确再继续往下做挤出。4.2 热成像颜色像彩虹糖问题不在调色板颜色不对的时候先不要急着怀疑颜色映射函数。多数情况下是温度场本身分布不对导致采样到的 t 没有层次。我的经验是温度场必须至少包含两层噪声一层低频用于控制整体冷暖分布一层高频用于增加细节。上面片元着色器里我用的是n1 * 0.45 n2 * 0.2低频噪声权重明显更高。如果你只采样一层高频噪声画面会像电视雪花只采样一层低频噪声画面太光滑没有热成像那种颗粒感。另外不要把 t 直接取0.0到1.0全程而是让它落在0.2到0.9之间。给 t 留一点上下缓冲颜色就不会被 clamp 到死黑或死白。4.3 React 严格模式下出现两个 Canvas前面提到过React 18 StrictMode 会模拟组件销毁再重新创建用来暴露一些不干净的副作用。如果你发现页面里有两个画布十有八九是 cleanup 函数里没有移除 DOM。还有一种情况是动画循环没有停掉。有些教程直接在useEffect里写requestAnimationFrame但是没在 cleanup 里cancelAnimationFrame。StrictMode 下第一次创建的循环会一直在后台运行第二次又会创建一个新的最终 GPU 占用越来越高。我的习惯是优先使用renderer.setAnimationLoop(null)来停止循环因为它能保证从底层把 XR 和帧循环都停掉。如果仍然不放心可以在 cleanup 里打印一行日志确认清理逻辑执行。4.4 渲染发黑、色带、噪点怎么优化渲染发黑先看背景色和 ShaderMaterial 的输出。把材质换成 MeshBasicMaterial 试一下如果还是黑说明几何体或相机有问题。如果只有 ShaderMaterial 黑检查 temp 值是否太小可以通过gl_FragColor vec4(vec3(temp), 1.0)先看温度场灰度图。色带明显热成像由于温度连续变化如果颜色映射的控制点太少会出现明显的带状断层。可以把映射函数改成 7 个控制点或者直接用一张 256x1 的 LUT 纹理让 t 从texture2D(uLut, vec2(t, 0.5))中取色这样色带平滑度会好很多。噪声闪烁严重如果噪声纹理分辨率太低且做了 RepeatWrapping在表面移动时容易看出重复纹理块。建议把纹理尺寸设为 128 或 256同时使用THREE.LinearFilter和THREE.LinearMipmapLinearFilter。4.5 常见问题速查表现象可能原因解决办法模型不显示路径解析为空用 ShapeGeometry 单独预览路径模型偏离中心几何体没有 center()调用 geometry.center()扫描线不动uniform 没更新检查渲染循环里 uScan 赋值整体偏黑baseTemp 太低调高到 0.3 以上泛光全白bloom threshold 太低提高 threshold 到 0.3 左右页面多 canvasStrictMode 清理不完整cleanup 里 removeChild移动端卡顿像素比过高renderer.setPixelRatio(Math.min(devicePixelRatio, 2))5. 扩展方向与我的实操体会5.1 从 logo 到产品、从模型到实时视频这套方案远不只是一个 logo 演示。如果你有 GLTF 格式的服务器模型可以读取设备的 CPU/GPU 温度实时写入 uniform 来驱动热成像颜色这本质上就是一个轻量级三维机房监控大屏。把 React 的 state 和 WebSocket 结合温度数据一变模型颜色就跟着变比传统数字表格直观得多。想做热成像人像的话思路也很清晰用 MediaPipe Selfie Segmentation 从摄像头视频里抠出人形区域然后把视频的灰度值当作温度输入再用同样的 thermalPalette 映射输出。那会比单纯给 logo 做热成像更有传播力。如果不想用 Mesh也可以把模型表面换成粒子系统。每个粒子根据位置计算温度值用 PointsMaterial 或者 ShaderMaterial 的 attribute 控制颜色视觉上会有一种“正在被红外扫描并且粒子化”的赛博感。5.2 我的真实心得和三条建议第一先把 shader 调色放到一个独立的 CodePen 或本地最小页面里做不要放在 React 项目里反复改。因为 React 热更新会重建整个场景编译出错时非常干扰判断独立页面里只有 100 行代码调整颜色曲线会快很多。第二Three.js 的资源释放要做全套。特别是 EffectComposer 会创建多个 RenderTarget如果不调用renderer.dispose()在单页应用里切换路由后 GPU 内存可能不会立刻回收。长时间运行下来会明显发烫。第三Apple logo 本身是受保护的商标这个 Demo 仅供学习和个人演示使用。如果你要放到带有商业性质的作品集或者公司官网建议换成自己的品牌 logo 或者用开源形象代替。技术逻辑完全一样。最后说句实在话如果你只是想抄一份代码跑起来那很快但如果你愿意把 ShaderMaterial 里的每一行都改一遍眼睛盯着颜色从冷变热、从平滑变颗粒你会真正理解热成像这种“标量场映射”的方法论。这套东西用到产品渲染、数据可视化、影视特效里也都是一样的逻辑。