Threenix DevelopersGet the Agent Skill

Loading preview...

Component

Scroll-Driven Image Sequence in React Three Fiber with WebGPU

July 29, 2026
ScrollImage SequenceTexturesR3FWebGPUTSLTypescript

Description

The demo stores its container scroll progress in a ref, then selects one of sixty transparent .webp frames for a full-screen TSL quad.

One sRGB texture is reused between frames, avoiding a canvas copy and keeping the GPU footprint near one full-HD texture. Use this pattern for scroll-driven product turns, reveals, and other pre-rendered motion.

Implementation

Source Code

ScrollDrivenImageSequence

'use client'

import { useFrame, useLoader, useLocalNodes } from '@react-three/fiber/webgpu'
import { type FC, type RefObject, useCallback, useEffect, useMemo } from 'react'
import {
  Fn,
  float,
  positionGeometry,
  screenSize,
  texture,
  varying,
  vec2,
  vec4,
} from 'three/tsl'
import {
  BufferAttribute,
  BufferGeometry,
  ImageLoader,
  LinearFilter,
  SRGBColorSpace,
  Sphere,
  Texture,
  Vector3,
} from 'three/webgpu'

type Props = {
  progress: RefObject<number>
}

const FRAME_ASPECT = float(16 / 9).toConst()

const ScrollDrivenImageSequence: FC<Props> = ({ progress }) => {
  const frameImages = useLoader(ImageLoader, FRAME_URLS)
  const screenQuadGeometry = useMemo(() => createScreenQuadGeometry(), [])
  const frameTexture = useMemo(() => {
    const nextTexture = new Texture(frameImages[0])
    nextTexture.colorSpace = SRGBColorSpace
    nextTexture.generateMipmaps = false
    nextTexture.magFilter = LinearFilter
    nextTexture.minFilter = LinearFilter
    nextTexture.needsUpdate = true
    return nextTexture
  }, [frameImages])

  const createNodes = useCallback(() => {
    const vUv = varying<'vec2'>(vec2(), 'vUv')

    const vertexNode = Fn(() => {
      const screenAspect = screenSize.x.div(screenSize.y)
      const visibleArea = vec2(
        screenAspect.div(FRAME_ASPECT).min(1),
        FRAME_ASPECT.div(screenAspect).min(1),
      )
      vUv.assign(
        positionGeometry.xy
          .mul(0.5)
          .add(0.5)
          .mul(visibleArea)
          .add(vec2(1).sub(visibleArea).mul(0.5)),
      )
      return vec4(positionGeometry.xy, 0, 1)
    })()

    return {
      colorNode: texture(frameTexture, vUv),
      setFrame: (progress: number) => {
        const frameIndex = Math.min(
          frameImages.length - 1,
          Math.max(0, Math.floor(progress * frameImages.length)),
        )
        const nextImage = frameImages[frameIndex]

        if (!nextImage || frameTexture.image === nextImage) return

        frameTexture.image = nextImage
        frameTexture.needsUpdate = true
      },
      vertexNode,
    }
  }, [frameImages, frameTexture])

  const nodes = useLocalNodes(createNodes)

  useEffect(() => () => frameTexture.dispose(), [frameTexture])

  useFrame(() => {
    nodes.setFrame(progress.current)
  })

  return (
    <mesh geometry={screenQuadGeometry} frustumCulled={false}>
      <meshBasicNodeMaterial
        colorNode={nodes.colorNode}
        depthTest={false}
        depthWrite={false}
        transparent={true}
        vertexNode={nodes.vertexNode}
      />
    </mesh>
  )
}

function createScreenQuadGeometry(): BufferGeometry {
  const geometry = new BufferGeometry()
  const vertices = new Float32Array([-1, -1, 3, -1, -1, 3])
  geometry.boundingSphere = new Sphere(new Vector3(), Infinity)
  geometry.setAttribute('position', new BufferAttribute(vertices, 2))
  return geometry
}

export default ScrollDrivenImageSequence

const FRAME_URLS: string[] = [
  new URL('./assets/pragma10001.webp', import.meta.url).href,
  new URL('./assets/pragma10002.webp', import.meta.url).href,
  new URL('./assets/pragma10003.webp', import.meta.url).href,
  new URL('./assets/pragma10004.webp', import.meta.url).href,
  new URL('./assets/pragma10005.webp', import.meta.url).href,
  new URL('./assets/pragma10006.webp', import.meta.url).href,
  new URL('./assets/pragma10007.webp', import.meta.url).href,
  new URL('./assets/pragma10008.webp', import.meta.url).href,
  new URL('./assets/pragma10009.webp', import.meta.url).href,
  new URL('./assets/pragma10010.webp', import.meta.url).href,
  new URL('./assets/pragma10011.webp', import.meta.url).href,
  new URL('./assets/pragma10012.webp', import.meta.url).href,
  new URL('./assets/pragma10013.webp', import.meta.url).href,
  new URL('./assets/pragma10014.webp', import.meta.url).href,
  new URL('./assets/pragma10015.webp', import.meta.url).href,
  new URL('./assets/pragma10016.webp', import.meta.url).href,
  new URL('./assets/pragma10017.webp', import.meta.url).href,
  new URL('./assets/pragma10018.webp', import.meta.url).href,
  new URL('./assets/pragma10019.webp', import.meta.url).href,
  new URL('./assets/pragma10020.webp', import.meta.url).href,
  new URL('./assets/pragma10021.webp', import.meta.url).href,
  new URL('./assets/pragma10022.webp', import.meta.url).href,
  new URL('./assets/pragma10023.webp', import.meta.url).href,
  new URL('./assets/pragma10024.webp', import.meta.url).href,
  new URL('./assets/pragma10025.webp', import.meta.url).href,
  new URL('./assets/pragma10026.webp', import.meta.url).href,
  new URL('./assets/pragma10027.webp', import.meta.url).href,
  new URL('./assets/pragma10028.webp', import.meta.url).href,
  new URL('./assets/pragma10029.webp', import.meta.url).href,
  new URL('./assets/pragma10030.webp', import.meta.url).href,
  new URL('./assets/pragma10031.webp', import.meta.url).href,
  new URL('./assets/pragma10032.webp', import.meta.url).href,
  new URL('./assets/pragma10033.webp', import.meta.url).href,
  new URL('./assets/pragma10034.webp', import.meta.url).href,
  new URL('./assets/pragma10035.webp', import.meta.url).href,
  new URL('./assets/pragma10036.webp', import.meta.url).href,
  new URL('./assets/pragma10037.webp', import.meta.url).href,
  new URL('./assets/pragma10038.webp', import.meta.url).href,
  new URL('./assets/pragma10039.webp', import.meta.url).href,
  new URL('./assets/pragma10040.webp', import.meta.url).href,
  new URL('./assets/pragma10041.webp', import.meta.url).href,
  new URL('./assets/pragma10042.webp', import.meta.url).href,
  new URL('./assets/pragma10043.webp', import.meta.url).href,
  new URL('./assets/pragma10044.webp', import.meta.url).href,
  new URL('./assets/pragma10045.webp', import.meta.url).href,
  new URL('./assets/pragma10046.webp', import.meta.url).href,
  new URL('./assets/pragma10047.webp', import.meta.url).href,
  new URL('./assets/pragma10048.webp', import.meta.url).href,
  new URL('./assets/pragma10049.webp', import.meta.url).href,
  new URL('./assets/pragma10050.webp', import.meta.url).href,
  new URL('./assets/pragma10051.webp', import.meta.url).href,
  new URL('./assets/pragma10052.webp', import.meta.url).href,
  new URL('./assets/pragma10053.webp', import.meta.url).href,
  new URL('./assets/pragma10054.webp', import.meta.url).href,
  new URL('./assets/pragma10055.webp', import.meta.url).href,
  new URL('./assets/pragma10056.webp', import.meta.url).href,
  new URL('./assets/pragma10057.webp', import.meta.url).href,
  new URL('./assets/pragma10058.webp', import.meta.url).href,
  new URL('./assets/pragma10059.webp', import.meta.url).href,
  new URL('./assets/pragma10060.webp', import.meta.url).href,
]