摘要

如上述引用内容,本文将基于 React + Three.js + GLSL 的相关知识,实现 Apple 2025 动态热成像 logo 效果。通过本文的阅读和学习,你将学习到的知识点包括:离屏渲染技术 FBO、交互事件与动态参数控制、Leva 控制面板的应用、视频纹理、遮罩纹理、着色器材质的使用、热成像动画着色器实现和应用等。

 

效果

本文页面实现效果如下图所示,页面页面中心由 Apple 热成像动态图标构成,图标上面由橙色和蓝色渐变色动态流动,页面底部为蓝色渐变文案。

 

preview

 

当使用鼠标 🖱️ 或触控板 👋 网页上按压或拖动 Logo 时,可以看到颜色随手势展开变化,看起来像是模拟真实热量轨迹。

 

ctrl

 

本专栏系列代码托管在 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:根据输入纹理的像素值,结合颜色映射参数,计算每个像素的最终颜色,实现热力图效果。

遮罩纹理图片预览

 

mask

 

④ 实现绘制渲染器组件 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 控制面板动态调节着色器参数。

 

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)

  })

}, [])

color1

 

⑦ 着色器

📦 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

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐