Apple 2025年度发布会LOGO以标志性的苹果图形被注入炽热的火焰质感,色彩从暖调橙黄向冷调湛蓝自然过渡,似高温灼烧下的金属表面,迸发出熔融的光泽;又若无形的能量在流动,勾勒出科技的脉搏与律动,将 “科技” 与 “力量” 的碰撞感具象化,光影的明暗交错削弱了平面的单薄感,赋予其近乎触手可及的质感,同时营造出浓郁的未来感与未知感。
摘要
如上述引用内容,本文将基于 React + Three.js + GLSL 的相关知识,实现 Apple 2025 动态热成像 logo 效果。通过本文的阅读和学习,你将学习到的知识点包括:离屏渲染技术 FBO、交互事件与动态参数控制、Leva 控制面板的应用、视频纹理、遮罩纹理、着色器材质的使用、热成像动画着色器实现和应用等。
效果
本文页面实现效果如下图所示,页面页面中心由 Apple 热成像动态图标构成,图标上面由橙色和蓝色渐变色动态流动,页面底部为蓝色渐变文案。
当使用鼠标 🖱️ 或触控板 👋 网页上按压或拖动 Logo 时,可以看到颜色随手势展开变化,看起来像是模拟真实热量轨迹。
本专栏系列代码托管在 Github 仓库【threejs-odessey】,后续所有目录也都将在此仓库中更新。
🔗 代码仓库地址:git@github.com:dragonir/threejs-odessey.git实现
本文代码实现效果参考自:https://github.com/vladmdgolam/apple-event-2025,实现内容模块旨在对其核心知识点进行汇总归纳学习,通过相同的原理并举一反三,实现专属自己的热成像动态 logo 😎。
① 资源引入
以下是实现苹果热成像所需的主要依赖资源,其中:OrthographicCamera用于创建平行投影相机、LinearFilter 是纹理采样过滤方式的常量,用于在控制纹理在放大缩小时的平滑过渡效果、ShaderMaterial 用于通过 GLSL创建自定义的着色器材质,是实现本案例效果的关键、VideoTexture可以将视频元素作为数据源创建动态的视频纹理、Leva 是一个轻量级的前端调试工具库,主要用于快速创建交互式控制面板,方便开发者在开发过程中实时调试热成像的各种参数等。其他的依赖都是创建三维场景必须的一些内容,具体作用可自行查阅。
import { OrthographicCamera, DoubleSide, LinearFilter, Mesh, RGBFormat, RepeatWrapping, ShaderMaterial, Texture, TextureLoader, VideoTexture, } from "three" import { Leva, levaStore, useControls } from "leva"② 页面场景初始化 HeatmapScene
使用 React Three Fiber 初始化场景、相机等,其中 Leva 组件用于动态可视化调试着色器的多种参数,Scene 组件用于渲染 logo 场景,是整个交互可视化效果的核心统筹层。
return ( <div> <Leva hidden={levaHidden} /> <InfoPanel onToggleControls={() => setLevaHidden((p) => !p)} onRandomizeColors={randomizeColors} /> <div ref={containerRef} className="w-[560px] h-[560px] touch-none select-none"> <Canvas orthographic camera={{ position: [0, 0, 1], left: -2, right: 2, top: 2, bottom: -2, near: -1, far: 1, }} gl={{ antialias: true, alpha: true, outputColorSpace: "srgb" }} flat > <Scene containerRef={containerRef} /> </Canvas> </div> <div className="dragonir">@dragonir</div> </div> )③ 实现动态热力图网格 HeatMesh
HeatMesh 组件,它主要通过视频纹理 VideoTexture、绘制纹理 drawTexture 和遮罩纹理 maskTexture 作为数据源,使用着色器材质 ShaderMaterial 渲染一个平面网格 planeGeometr,实现了可实时调整的热力图效果。ShaderMaterial 通过传入自定义顶点着色器和片元着色器实现复杂的热力图色彩映射和动态效果。
export const HeatMesh = ({ drawTexture }: { drawTexture: Texture | null }) => { const timeRef = useRef(0) const videoRef = useRef<HTMLVideoElement | null>(null) const [videoTexture, setVideoTexture] = useState<VideoTexture | null>(null) // Leva 控制面板着色器参数:power(强度)、opacity(透明度)、颜色映射、混合与过渡等参数可实时调整。 const { power, opacity, color1, blend1, fade1,maxBlend4 ...} = useControls("Heat Map", {}) // 遮罩纹理 const maskTexture = useLoader(TextureLoader, "/logo.png") useEffect(() => { if (maskTexture) { maskTexture.wrapS = maskTexture.wrapT = RepeatWrapping maskTexture.needsUpdate = true } }, [maskTexture]) // 视频纹理 useEffect(() => { const video = document.createElement("video") video.src = "/apple.mp4" video.loop = true video.playsInline = true video.autoplay = true video.preload = "auto" const onVideoLoad = () => { const texture = new VideoTexture(video) texture.minFilter = LinearFilter texture.magFilter = LinearFilter texture.format = RGBFormat setVideoTexture(texture) } }, []) // 着色器材质 const material = useMemo(() => { return new ShaderMaterial({ uniforms: { blendVideo: { value: 1.0 }, drawMap: { value: drawTexture }, textureMap: { value: videoTexture || maskTexture }, maskMap: { value: maskTexture }, opacity: { value: opacity }, amount: { value: 1.0 }, color1: { value: color1 }, blend: { value: [blend1, blend2, blend3, blend4] }, fade: { value: [fade1, fade2, fade3, fade4] }, power: { value: power }, rnd: { value: 0 }, maxBlend: { value: [maxBlend1, maxBlend2, maxBlend3, maxBlend4] }, heat: { value: [0, 0, 0, 1.02] }, stretch: { value: [1, 1, 0, 0] }, }, vertexShader: heatVertexShader, fragmentShader: heatFragmentShader, transparent: true, side: DoubleSide, }) }, [...]) // 动态更新与渲染,通过 useFrame 钩子每帧更新时间和随机值,使热力图呈现动态变化 useFrame((_, delta) => { timeRef.current += delta if (material) { material.uniforms.rnd.value = Math.random() material.uniforms.amount.value = 1.0 } }) // 渲染一个平面网格,应用自定义着色器材质,作为热力图的载体 return ( <mesh> <planeGeometry /> <primitive object={material} /> </mesh> ) }其中 heatVertexShader 和 heatFragmentShader 是着色器材质的顶点着色器和片元着色器,它们的详细内容见文章最后的着色器模块。
- 顶点着色器
heatVertexShader:处理网格顶点的位置变换 - 片元着色器
heatFragmentShader:根据输入纹理的像素值,结合颜色映射参数,计算每个像素的最终颜色,实现热力图效果。
遮罩纹理图片预览
④ 实现绘制渲染器组件 DrawRenderer
DrawRenderer 组件的主要作用是实时处理动态绘制输入,通过双 FBO交替渲染机制,通过缓冲和自定义着色器实现渲染累积与渐隐效果,并接收外部输入的绘制位置、方向、强度等参数,通过自定义着色器实时更新绘制纹理,并将结果传递给外部使用。
// 通过引入 useFBO 创建帧缓冲对象,用于在GPU上存储和处理绘制纹理。 import { useFBO } from "@react-three/drei" const fboParams = { type: FloatType, format: RGBAFormat, minFilter: LinearFilter, magFilter: LinearFilter, } export const DrawRenderer = ({ size = 256, position, direction, drawAmount, onTextureUpdate, sizeDamping, fadeDamping, radiusSize }) => { const { size: canvasSize } = useThree() const dynamicRadius = radiusSize const fboA = useFBO(size, size, fboParams) const fboB = useFBO(size, size, fboParams) const renderTargets = useMemo(() => ({ current: fboA, previous: fboB }), [fboA, fboB]) const { drawScene, drawCamera, material } = useMemo(() => { const drawScene = new Scene() const drawCamera = new OrthographicCamera(-0.5, 0.5, 0.5, -0.5, 0.1, 10) drawCamera.position.z = 1 // 通过 ShaderMaterial 定义绘制的核心逻辑,着色器接收外部参数并更新 FBO 纹理 const material = new ShaderMaterial({ uniforms: { uRadius: { value: [-8, 0.9, dynamicRadius] }, uPosition: { value: [0, 0] }, uDirection: { value: [0, 0, 0, 0] }, uResolution: { value: [canvasSize.width, canvasSize.height, 1] }, uTexture: { value: renderTargets.previous.texture }, uSizeDamping: { value: sizeDamping }, uFadeDamping: { value: fadeDamping }, uDraw: { value: 0 }, }, // 处理平面顶点的坐标转换,确保与 FBO 纹理坐标对齐 vertexShader: drawVertexShader, // 根据输入的 uPosition uRadius等参数,在上一帧纹理uTexture的基础上绘制新的渐变,并应用衰减uFadeDamping使旧渐变渐消失,实现动态流动效果 fragmentShader: drawFragmentShader, depthTest: false, transparent: true, }) // 创建一个平面网格,作为绘制的画布 const mesh = new Mesh(new PlaneGeometry(1, 1), material) drawScene.add(mesh) return { drawScene, drawCamera, material } }, [renderTargets, dynamicRadius, sizeDamping, fadeDamping, canvasSize]) // Update 着色器变量参数同步:通过 useEffect 将外部传入的 position、direction、drawAmount等参数实时更新到着色器的 uniforms 中 useEffect(() => { material.uniforms.uRadius.value[2] = dynamicRadius material.uniforms.uPosition.value = position material.uniforms.uDirection.value = direction material.uniforms.uDraw.value = drawAmount }, [material, dynamicRadius, position, direction, drawAmount]) // 帧循环:每帧执行以下操作:将上一帧的FBO纹理previous作为输入传递给着色器;切换渲染目标到当前FBO current,渲染绘制场景;交换current和previous的角色,准备下一帧的累积; useFrame(({ gl }) => { const currentTarget = renderTargets.current const previousTarget = renderTargets.previous material.uniforms.uTexture.value = previousTarget.texture const originalTarget = gl.getRenderTarget() gl.setRenderTarget(currentTarget) gl.clear() gl.render(drawScene, drawCamera) gl.setRenderTarget(originalTarget) const temp = renderTargets.current renderTargets.current = renderTargets.previous renderTargets.previous = temp // 通过 onTextureUpdate回调,将当前 FBO 的纹理传递给外部 onTextureUpdate(currentTarget.texture) }) // 组件本身不渲染任何可见元素,仅负责后台处理绘制纹理 return null }💡 帧缓冲对象 FBO 与双缓冲机制
FBO作用:FBO是GPU上的离屏渲染目标,用于存储中间绘制结果,避免直接渲染到屏幕,提高效率;- 双缓冲设计:创建两个
FBO(fboA和fboB),通过renderTargets管理当前帧current和上一帧previous - 每帧将上一帧的
FBO纹理作为输入,绘制新内容到当前FBO,然后交换两者的角色,实现绘制效果的热力图的渐隐效果。
⑤ 创建渲染场景组件 Scene
Scene 组件是整个交互可视化效果的核心统筹组件,它主要实现的功能包括:整合鼠标交互、参数控制、绘制渲染DrawRenderer 与热力图渲染 HeatMesh,实现鼠标 hover 或者 移动时生成动态热力图。最终实现的效果是:用户在画布上移动鼠标,鼠标轨迹会实时生成带有热力渐变的动态效果,且效果可通过 Leva 面板参数可以实时调整。
import { DrawRenderer } from "./DrawRenderer" import { HeatMesh } from "./HeatMesh" export const Scene = ({ containerRef, }: { containerRef: React.RefObject<HTMLDivElement | null> }) => { const [mouse, setMouse] = useState<[number, number]>([0, 0]) const [heatAmount, setHeatAmount] = useState(0) const [drawTexture, setDrawTexture] = useState<Texture | null>(null) const heatRef = useRef(0) const lastMousePos = useRef<[number, number]>([0, 0]) const lastTime = useRef(performance.now()) const holdRef = useRef(false) const { camera, size } = useThree((state) => ({ camera: state.camera, size: state.size })) // Leva 控制参数增加 const { sizeDamping, fadeDamping, heatSensitivity, heatDecay, radiusSize } = useControls("Hover Heat",{ // 控制粗细的变化平滑度 sizeDamping: { value: 0.8, min: 0.0, max: 1.0, step: 0.01 }, // 控制消失的速度 fadeDamping: { value: 0.98, min: 0.9, max: 1.0, step: 0.001 }, // 鼠标移动时热度累积的快慢 heatSensitivity: { value: 0.25, min: 0.1, max: 2.0, step: 0.05 }, // 鼠标停止后热度下降的快慢 heatDecay: { value: 0.92, min: 0.8, max: 0.99, step: 0.01 }, // 控制单次绘制的范围大小 radiusSize: { value: 75, min: 20, max: 300, step: 5 }, } ) // 根据画布尺寸计算相机的宽高比,动态设置 等参数;确保相机的投影矩阵实时更新 useEffect(() => { if (camera && camera instanceof OrthographicCamera) { const aspect = size.width / size.height let width, height if (aspect >= 1) { height = 1 width = aspect } else { width = 1 height = 1 / aspect } camera.left = -width / 2 camera.right = width / 2 camera.top = height / 2 camera.bottom = -height / 2 camera.near = -1 camera.far = 1 camera.updateProjectionMatrix() } }, [camera, size]) // 通过pointermove/pointerleave事件监听鼠标在容器内的位置,计算鼠标相对于容器的归一化坐标 const handleDOMPointerMove = useCallback( (e: PointerEvent) => { if (containerRef.current) { const rect = containerRef.current.getBoundingClientRect() const clientX = e.clientX - rect.x const clientY = e.clientY - rect.y const normalizedX = clientX / rect.width const normalizedY = clientY / rect.height const x = 2 * (normalizedX - 0.5) const y = 2 * -(normalizedY - 0.5) holdRef.current = true setMouse([x, y]) lastMousePos.current = [x, y] lastTime.current = performance.now() } }, [containerRef] ) const handleDOMPointerLeave = useCallback(() => { holdRef.current = false }, []) // 鼠标事件监听 useEffect(() => { const canvas = containerRef.current if (!canvas) return canvas.addEventListener("pointermove", handleDOMPointerMove) canvas.addEventListener("pointerleave", handleDOMPointerLeave) return () => { canvas.removeEventListener("pointermove", handleDOMPointerMove) canvas.removeEventListener("pointerleave", handleDOMPointerLeave) } }, [handleDOMPointerMove, handleDOMPointerLeave, containerRef]) useFrame((_, delta) => { // 热度累积:当鼠标在容器内移动holdRef.current = true时,根据heatSensitivity和帧间隔delta计算热度增量,heatRef.current持续累积最大限制为1.3,避免强度溢出 if (holdRef.current) { const heatIncrease = heatSensitivity * delta * 60 heatRef.current += heatIncrease heatRef.current = Math.min(1.3, heatRef.current) setHeatAmount(heatRef.current) // 热度衰减:当鼠标离开容器pointerleave或停止移动时,热度值按 heatDecay衰减系数逐步降低,直到低于0.001时清零; } else if (heatRef.current > 0) { heatRef.current *= heatDecay heatRef.current = heatRef.current < 0.001 ? 0 : heatRef.current setHeatAmount(heatRef.current) } // 延迟重置:鼠标停止移动后,通过50ms延迟将 holdRef设为false,避免因短暂停顿导致热度突然中断,模拟自然残留感 if (holdRef.current) { setTimeout(() => { holdRef.current = false }, 50) } }) const direction = useMemo<[number, number, number, number]>(() => { return [0, 0, 0, 100] }, []) const drawPosition = useMemo<[number, number]>(() => { const x = 0.5 * mouse[0] + 0.5 const y = 0.5 * mouse[1] + 0.5 return [x, y] }, [mouse]) // 向 DrawRenderer 传递绘制数据,接收绘制结果并传递给 HeatMesh return ( <> <DrawRenderer size={256} position={drawPosition} direction={direction} drawAmount={heatAmount} onTextureUpdate={setDrawTexture} sizeDamping={sizeDamping} fadeDamping={fadeDamping} radiusSize={radiusSize} /> <HeatMesh drawTexture={drawTexture} /> </> ) }通过 Leva 控制面板动态调节着色器参数。
⑥ 自定义颜色功能实现
可以通过如下的方法,生成随机色彩并将生成的参数传递到着色器,可以实现热力图 logo 颜色的动态切换。
const randomizeColors = useCallback(() => { const hslToHex = (h: number, s: number, l: number) => { s /= 100 l /= 100 const k = (n: number) => (n + h / 30) % 12 const a = s * Math.min(l, 1 - l) const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))) const toHex = (x: number) => Math.round(255 * x).toString(16).padStart(2, "0") return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}` } // 生成6种随机颜色 color2..color7,color1保持黑色 const base = Math.floor(Math.random() * 360) const steps = [15, 35, 55, 85, 140, 200] const palette = steps.map((step, i) => hslToHex((base + step) % 360, 80 - i * 4, 50 + (i - 3) * 3)) const keys = ["color2", "color3", "color4", "color5", "color6", "color7"] as const keys.forEach((key, i) => { levaStore.setValueAtPath(`Heat Map.${key}`, palette[i], false) }) }, [])⑦ 着色器
📦 draw.frag
precision highp float; uniform float uDraw; uniform vec3 uRadius; uniform vec3 uResolution; uniform vec2 uPosition; uniform vec4 uDirection; uniform float uSizeDamping; uniform float uFadeDamping; uniform sampler2D uTexture; varying vec2 vUv; void main() { float aspect = uResolution.x / uResolution.y; vec2 pos = uPosition; pos.y /= aspect; vec2 uv = vUv; uv.y /= aspect; float dist = distance(pos, uv) / (uRadius.z / uResolution.x); dist = smoothstep(uRadius.x, uRadius.y, dist); vec3 dir = uDirection.xyz * uDirection.w; vec2 offset = vec2((-dir.x) * (1.0-dist), (dir.y) * (1.0-dist)); vec2 uvt = vUv; vec4 color = texture2D(uTexture, uvt + (offset * 0.01)); color *= uFadeDamping; color.r += offset.x; color.g += offset.y; color.rg = clamp(color.rg, -1.0, 1.0); float d = uDraw; color.b += d * (1.0-dist); gl_FragColor = vec4(color.rgb, 1.0); }📦 heat.frag
precision highp isampler2D; precision highp usampler2D; uniform sampler2D drawMap; uniform sampler2D textureMap; uniform sampler2D maskMap; uniform float amount; uniform float opacity; uniform vec3 color1; uniform vec3 color2; uniform vec3 color3; uniform vec3 color4; uniform vec3 color5; uniform vec3 color6; uniform vec3 color7; uniform vec4 blend; uniform vec4 fade; uniform vec4 maxBlend; uniform float power; varying vec2 vUv; varying vec4 vClipPosition; vec3 linearRgbToLuminance(vec3 linearRgb){ float finalColor = dot(linearRgb, vec3(0.2126729, 0.7151522, 0.0721750)); return vec3(finalColor); } vec3 saturation(vec3 color, float saturation){ return mix(linearRgbToLuminance(color), color, saturation); } vec3 gradient(float t) { float p1 = blend.x; float p2 = blend.y; float p3 = blend.z; float p4 = blend.w; float p5 = maxBlend.x; float p6 = maxBlend.y; float f1 = fade.x; float f2 = fade.y; float f3 = fade.z; float f4 = fade.w; float f5 = maxBlend.z; float f6 = maxBlend.w; float blend1 = smoothstep(p1 - f1 * 0.5, p1 + f1 * 0.5, t); float blend2 = smoothstep(p2 - f2 * 0.5, p2 + f2 * 0.5, t); float blend3 = smoothstep(p3 - f3 * 0.5, p3 + f3 * 0.5, t); float blend4 = smoothstep(p4 - f4 * 0.5, p4 + f4 * 0.5, t); float blend5 = smoothstep(p5 - f5 * 0.5, p5 + f5 * 0.5, t); float blend6 = smoothstep(p6 - f6 * 0.5, p6 + f6 * 0.5, t); vec3 color = color1; color = mix(color, color2, blend1); color = mix(color, color3, blend2); color = mix(color, color4, blend3); color = mix(color, color5, blend4); color = mix(color, color6, blend5); color = mix(color, color7, blend6); return color; } void main() { vec2 duv = vClipPosition.xy/vClipPosition.w; duv = 0.5 + duv * 0.5; vec2 uv = vUv; uv -= 0.5; uv += 0.5; float o = clamp(opacity, 0.0, 1.0); float a = clamp(amount, 0.0, 1.0); float v = o * a; vec4 tex = texture2D(maskMap, uv); float mask = tex.g; float logo = smoothstep(0.58, 0.6, 1.0-tex.b); vec2 wuv = uv; vec3 draw = texture2D(drawMap, duv).rgb; float heatDraw = draw.b; heatDraw *= mix(0.1, 1.0, mask); vec2 offset2 = draw.rg * 0.01; vec3 video = textureLod(textureMap, wuv + offset2, 0.0).rgb; float h = mix(pow(1.0-video.r, 1.5), 1.0, 0.2) * 1.25; heatDraw *= h; float map = video.r; map = pow(map, power); float msk = smoothstep(0.2, 0.5, uv.y); map = mix( map * 0.91, map, msk); map = mix(0.0, map, v); float fade2 = distance(vUv, vec2(0.5, 0.52)); fade2 = smoothstep(0.5, 0.62, 1.0-fade2); vec3 finalColor = gradient(map + heatDraw); finalColor = saturation(finalColor, 1.3); finalColor *= fade2; finalColor = mix(vec3(0.0), finalColor, a); gl_FragColor = vec4(finalColor, 1.0); }总结
📌 本项目代码主要由 4 个核心组件构成,其中:
- HeatmapScene:是全局容器,作为顶层组件,管理
Three.js、Leva 控制面板和其他页面信息;通过levaStore全局管理热力图颜色参数,传递容器引用给子组件。 - Scene:交互与统筹,处理鼠标交互,计算热度值,模拟鼠标轨迹的累积与衰减,串联
DrawRenderer和HeatMesh,传递交互参数。 - DrawRenderer:绘制处理,使用双帧缓冲
FBO实现离屏绘制,高效累积鼠标轨迹,通过自定义着色器处理轨迹的绘制、渐隐与衰减,输出处理后的绘制纹理drawTexture给HeatMesh。 - HeatMesh:热力图渲染,基于
DrawRenderer输出的纹理,结合视频纹理和遮罩纹理,通过自定义着色器生成热力图效果。
📌 本文中主要包含的新知识点如下:
Three.js离屏渲染技术FBO:通过useFBO创建帧缓冲对象,实现GPU层面的离屏绘制,避免直接操作DOM提升性能;双缓冲机制fboA/fboB交替渲染实现绘制轨迹的累积与动态更新。交互事件与动态参数控制:鼠标键盘事件监听:将用户输入转换为可量化的参数。Leva控制面板:通过useControls实时调整视觉参数,提升开发灵活性。- 视频纹理、遮罩纹理、着色器材质的使用等。
想了解其他前端知识或其他未在本文中详细描述的Web 3D开发技术相关知识,可阅读我往期的文章。如果有疑问可以在评论中留言,如果觉得文章对你有帮助,不要忘了一键三连哦 👍。
附录
- [1]. 🌴 Three.js 打造缤纷夏日3D梦中情岛
- [2]. 🔥 Three.js 实现炫酷的赛博朋克风格3D数字地球大屏
- [3]. 🐼 Three.js 实现2022冬奥主题3D趣味页面,含冰墩墩
- [4]. 🦊 Three.js 实现3D开放世界小游戏:阿狸的多元宇宙
- [5]. 🏡 Three.js 进阶之旅:全景漫游-高阶版在线看房
...- 【Three.js 进阶之旅】系列专栏访问 👈
- 更多往期【3D】专栏访问 👈
- 更多往期【前端】专栏访问 👈
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用。你还可以使用@来通知其他用户。