Codrops 원본

Building a Scroll-Reactive 3D Gallery with Three.js, Velocity, and Mood-Based Backgrounds

스크롤 · MIT

스크롤 더 보기
ORIGINAL PREVIEW
Building a Scroll-Reactive 3D Gallery with Three.js, Velocity, and Mood-Based Backgrounds 정적 미리보기

갤러리 안에서는 화면을 벗어나거나 움직임을 멈추면 예제도 닫힙니다. 다시 재생할 때는 처음부터 시작해요.

큰 화면으로 보기 새 탭

SOURCE FILES

원본 코드 읽기

22개 파일

수집한 원본 소스와 실행 안내를 함께 제공합니다.

eslint.config.mjs
파일 저장

import js from '@eslint/js'
import globals from 'globals'
import { defineConfig } from 'eslint/config'

export default defineConfig([
  {
    files: ['**/*.{js,mjs,cjs}'],
    plugins: { js },
    extends: ['js/recommended'],
    languageOptions: { globals: { ...globals.browser, ...globals.node } },
  },
])
index.html
파일 저장

<!doctype html>
<html lang="en" class="no-js">

<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Atmospheric Depth Gallery | Codrops</title>
  <meta
        name="description"
        content="A Three.js depth gallery where images shape their own environment — mood-driven GLSL backgrounds, parallax motion, and velocity-reactive effects." />
  <meta
        name="keywords"
        content="three.js, webgl, codrops, scroll, 3d gallery, depth, z-axis, mood, motion" />
  <meta name="author" content="Codrops" />
  <link rel="icon" type="image/svg+xml" href="https://tympanus.net/favicon/favicon.svg" />
  <link rel="shortcut icon" href="https://tympanus.net/favicon/favicon.ico" />
  <script>
    document.documentElement.className = 'js'
  </script>
</head>

<body class="demo-1">
  <main>
    <header class="frame">
      <h1 class="frame__title">
        Atmospheric Depth Gallery by
        <a href="https://houmahani.com" target="_blank">Houmahani Kane</a>
      </h1>
      <a class="frame__back" href="https://tympanus.net/codrops/?p=111409">Article</a>
      <a class="frame__archive" href="https://tympanus.net/codrops/hub/">All demos</a>
      <a class="frame__github" href="https://github.com/houmahani/codrops-depth-gallery/">GitHub</a>
      <nav class="frame__tags">
        <a href="https://tympanus.net/codrops/hub/tag/scroll/">#scroll</a>
        <a href="https://tympanus.net/codrops/hub/tag/three-js/">#three.js</a>
        <a href="https://tympanus.net/codrops/hub/tag/webgl/">#webgl</a>
        <a href="https://tympanus.net/codrops/hub/tag/glsl/">#glsl</a>
      </nav>
      <nav class="frame__credit">
        <p>
          Color chip layout inspired by
          <a href="https://www.instagram.com/p/DU2JTU_AEwM/?img_index=3" target="_blank">@thisislandscape</a>
        </p>
        <p>Press D to see the debug tools</p>
      </nav>
    </header>
    <div class="content">
      <canvas class="webgl"></canvas>
    </div>
  </main>
  <script type="module" src="./src/main.js"></script>
</body>

</html>
함께 쓰는 파일 20개 보기
src/Experience/Background/index.js
파일 저장

import * as THREE from 'three'
import vertexShader from '@/Experience/Background/shaders/vertex.glsl'
import fragmentShader from '@/Experience/Background/shaders/fragment.glsl'

class Background {
  constructor(debug = null) {
    this.debug = debug
    this.isInitialized = false
    this.isDebugBound = false

    this.scene = null
    this.camera = null
    this.material = null
    this.mesh = null

    this.backgroundColor = new THREE.Color('#FBE8CD')
    this.blob1Color = new THREE.Color('#FFD56D')
    this.blob2Color = new THREE.Color('#5D816A')
    this.nextBackgroundColor = new THREE.Color()
    this.nextBlob1Color = new THREE.Color()
    this.nextBlob2Color = new THREE.Color()

    this.baseBlobRadius = 0.65
    this.secondaryBlobRadiusRatio = 0.78
    this.baseBlobStrength = 0.9

    this.depthToRadiusAmount = 0.08
    this.velocityToStrengthAmount = 0.1
    this.motionSmoothing = 0.1
    this.motionDepthProgress = 0
    this.motionVelocityIntensity = 0
    this.smoothedDepthProgress = 0
    this.smoothedVelocityIntensity = 0

    this.blobRadius = this.baseBlobRadius
    this.blobStrength = this.baseBlobStrength
    this.noiseStrength = 0.04
  }

  init() {
    if (this.isInitialized) return

    this.scene = new THREE.Scene()
    this.camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1)

    this.material = new THREE.ShaderMaterial({
      vertexShader,
      fragmentShader,
      depthWrite: false,
      depthTest: false,
      uniforms: {
        uBackgroundColor: { value: this.backgroundColor },
        uBlob1Color: { value: this.blob1Color },
        uBlob2Color: { value: this.blob2Color },
        uNoiseStrength: { value: this.noiseStrength },
        uBlobRadius: { value: this.blobRadius },
        uBlobRadiusSecondary: { value: this.blobRadius * this.secondaryBlobRadiusRatio },
        uBlobStrength: { value: this.blobStrength },
        uTime: { value: 0 },
        uVelocityIntensity: { value: 0 },
      },
    })

    const geometry = new THREE.PlaneGeometry(2, 2)
    this.mesh = new THREE.Mesh(geometry, this.material)
    this.scene.add(this.mesh)
    this.applyMotionToBlob()
    this.bindDebug()

    this.isInitialized = true
  }

  setMoodColors({ background, blob1, blob2 } = {}) {
    if (background) this.backgroundColor.set(background)
    if (blob1) this.blob1Color.set(blob1)
    if (blob2) this.blob2Color.set(blob2)

    this.updateUniformColors()
  }

  setMoodBlend({ currentMood, nextMood, blend } = {}) {
    if (!currentMood) return

    const safeBlend = THREE.MathUtils.clamp(blend ?? 0, 0, 1)
    if (!nextMood || safeBlend <= 0) {
      this.setMoodColors(currentMood)
      return
    }

    this.backgroundColor
      .set(currentMood.background)
      .lerp(this.nextBackgroundColor.set(nextMood.background), safeBlend)
    this.blob1Color.set(currentMood.blob1).lerp(this.nextBlob1Color.set(nextMood.blob1), safeBlend)
    this.blob2Color.set(currentMood.blob2).lerp(this.nextBlob2Color.set(nextMood.blob2), safeBlend)

    this.updateUniformColors()
  }

  updateUniformColors() {
    if (!this.material) return

    this.material.uniforms.uBackgroundColor.value.copy(this.backgroundColor)
    this.material.uniforms.uBlob1Color.value.copy(this.blob1Color)
    this.material.uniforms.uBlob2Color.value.copy(this.blob2Color)
    this.material.uniforms.uNoiseStrength.value = this.noiseStrength
  }

  updateBlobUniforms() {
    if (!this.material) return

    this.material.uniforms.uBlobRadius.value = this.blobRadius
    this.material.uniforms.uBlobRadiusSecondary.value =
      this.blobRadius * this.secondaryBlobRadiusRatio
    this.material.uniforms.uBlobStrength.value = this.blobStrength
  }

  setMotionResponse({ depthProgress, velocityIntensity } = {}) {
    if (Number.isFinite(depthProgress)) {
      this.motionDepthProgress = THREE.MathUtils.clamp(depthProgress, 0, 1)
    }
    if (Number.isFinite(velocityIntensity)) {
      this.motionVelocityIntensity = THREE.MathUtils.clamp(velocityIntensity, 0, 1)
    }
  }

  applyMotionToBlob() {
    const nextBlobRadius =
      this.baseBlobRadius + this.smoothedDepthProgress * this.depthToRadiusAmount
    const nextBlobStrength =
      this.baseBlobStrength + this.smoothedVelocityIntensity * this.velocityToStrengthAmount

    this.blobRadius = THREE.MathUtils.clamp(nextBlobRadius, 0.05, 1)
    this.blobStrength = THREE.MathUtils.clamp(nextBlobStrength, 0, 1)

    this.updateBlobUniforms()
  }

  bindDebug() {
    if (!this.debug || this.isDebugBound) return

    this.debug.addBinding({
      folderTitle: 'Background',
      targetObject: this,
      property: 'baseBlobRadius',
      label: 'Blob Radius',
      options: { min: 0.1, max: 1, step: 0.01 },
      onChange: () => {
        this.applyMotionToBlob()
      },
    })

    this.debug.addBinding({
      folderTitle: 'Background',
      targetObject: this,
      property: 'secondaryBlobRadiusRatio',
      label: 'Blob 2 Size',
      options: { min: 0.3, max: 1.2, step: 0.01 },
      onChange: () => {
        this.applyMotionToBlob()
      },
    })

    this.debug.addBinding({
      folderTitle: 'Background',
      targetObject: this,
      property: 'baseBlobStrength',
      label: 'Blob Strength',
      options: { min: 0, max: 1, step: 0.01 },
      onChange: () => {
        this.applyMotionToBlob()
      },
    })

    this.debug.addBinding({
      folderTitle: 'Background',
      targetObject: this,
      property: 'noiseStrength',
      label: 'Noise',
      options: { min: 0, max: 0.2, step: 0.005 },
      onChange: () => {
        this.updateUniformColors()
      },
    })

    this.isDebugBound = true
  }

  update(time = 0) {
    this.smoothedDepthProgress = THREE.MathUtils.lerp(
      this.smoothedDepthProgress,
      this.motionDepthProgress,
      this.motionSmoothing
    )
    this.smoothedVelocityIntensity = THREE.MathUtils.lerp(
      this.smoothedVelocityIntensity,
      this.motionVelocityIntensity,
      this.motionSmoothing
    )

    if (this.material) {
      this.material.uniforms.uTime.value = time
      this.material.uniforms.uVelocityIntensity.value = this.smoothedVelocityIntensity
    }

    this.applyMotionToBlob()
  }

  render(renderer) {
    if (!this.isInitialized) return
    renderer.render(this.scene, this.camera)
  }

  dispose() {
    if (!this.isInitialized) return

    this.mesh.geometry.dispose()
    this.material.dispose()
    this.scene.clear()

    this.scene = null
    this.camera = null
    this.mesh = null
    this.material = null
    this.isInitialized = false
  }
}

export { Background }
src/Experience/Background/shaders/fragment.glsl
파일 저장

varying vec2 vUv;

uniform vec3 uBackgroundColor;
uniform vec3 uBlob1Color;
uniform vec3 uBlob2Color;
uniform float uNoiseStrength;
uniform float uBlobRadius;
uniform float uBlobRadiusSecondary;
uniform float uBlobStrength;
uniform float uTime;
uniform float uVelocityIntensity;

float random(vec2 coord) {
  return fract(sin(dot(coord, vec2(12.9898, 78.233))) * 43758.5453123);
}

void main() {
  // Flat background color
  vec3 color = uBackgroundColor;

  // Blob centers subtely animated
  float animTime = uTime * 0.00028;
  vec2 blob1Center = vec2(
    0.50 + sin(animTime * 1.000) * 0.13 + sin(animTime * 1.618) * 0.05,
    0.48 + cos(animTime * 0.794) * 0.09 + cos(animTime * 1.272) * 0.03
  );
  vec2 blob2Center = vec2(
    0.35 + cos(animTime * 0.927) * 0.11 + cos(animTime * 1.414) * 0.04,
    0.55 + sin(animTime * 1.175) * 0.07 + sin(animTime * 0.618) * 0.03
  );

  // Two blobs: 1 at center, 0 at edge
  float blob1 = smoothstep(uBlobRadius, 0.0, distance(vUv, blob1Center));
  float blob2 = smoothstep(uBlobRadiusSecondary, 0.0, distance(vUv, blob2Center));

  // Soften blob colors toward background before applying
  vec3 blob1SoftColor = mix(uBlob1Color, uBackgroundColor, 0.35);
  vec3 blob2SoftColor = mix(uBlob2Color, uBackgroundColor, 0.35);
  color = mix(color, blob1SoftColor, blob1 * uBlobStrength);
  color = mix(color, blob2SoftColor, blob2 * uBlobStrength);

  // Velocity luminance lift
  color += uVelocityIntensity * 0.10;

  // Film grain
  float grain = random(vUv * vec2(1387.13, 947.91)) - 0.5;
  color += grain * uNoiseStrength;
  color = clamp(color, 0.0, 1.0);

  gl_FragColor = vec4(color, 1.0);
}
src/Experience/Background/shaders/vertex.glsl
파일 저장

varying vec2 vUv;

void main() {
  vUv = uv;
  gl_Position = vec4(position.xy, 0.0, 1.0);
}
src/Experience/Debug.js
파일 저장

import { Pane } from 'tweakpane'

class Debug {
  constructor() {
    this.pane = null
    this.folders = new Map()
    this.isVisible = false
  }

  init() {
    if (this.pane) return this.pane

    this.pane = new Pane({ title: 'Debug' })
    this.pane.element.classList.add('debug-pane')
    this.setVisible(this.isVisible)
    return this.pane
  }

  setVisible(isVisible) {
    this.isVisible = isVisible
    if (!this.pane) return
    this.pane.element.style.display = isVisible ? 'block' : 'none'
  }

  getFolder(folderTitle) {
    this.init()
    if (this.folders.has(folderTitle)) {
      return this.folders.get(folderTitle)
    }

    const folder = this.pane.addFolder({ title: folderTitle, expanded: true })
    this.folders.set(folderTitle, folder)
    return folder
  }

  addBinding({ folderTitle, targetObject, property, label, options = {}, onChange }) {
    const folder = this.getFolder(folderTitle)
    const binding = folder.addBinding(targetObject, property, {
      label,
      ...options,
    })

    binding.on('change', (event) => {
      if (onChange) {
        onChange(event.value)
      }
    })

    return binding
  }

  dispose() {
    if (!this.pane) return
    this.pane.dispose()
    this.pane = null
    this.folders.clear()
  }
}

export { Debug }
src/Experience/Engine.js
파일 저장

import * as THREE from 'three'
import Stats from 'three/examples/jsm/libs/stats.module.js'
import { world } from '@/Experience/'
import { Scroll } from '@/Experience/Scroll'

class Engine {
  constructor(canvas, experience = world) {
    if (!(canvas instanceof HTMLCanvasElement)) {
      throw new Error('Engine requires a valid canvas element')
    }

    // Initialization
    this.canvas = canvas
    this.experience = experience
    this.debug = this.experience.debug
    this.isInitialized = false
    this.isRunning = false
    this.isDebugBound = false
    this.animationFrameRequestId = null
    this.preloadedTextures = new Map()
    this.stats = null
    this.showFps = true
    this.isDebugUiVisible = false
    this.scene = new THREE.Scene()

    // Camera
    this.camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100)
    this.camera.position.set(0, 0, 6)

    // Scroll
    this.scroll = new Scroll(this.camera, this.experience.gallery, this.debug)

    // Renderer
    this.renderer = new THREE.WebGLRenderer({
      canvas: this.canvas,
      antialias: true,
    })
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
    this.renderer.outputColorSpace = THREE.SRGBColorSpace
    this.renderer.autoClear = false

    this.onResize = () => {
      this.resize()
    }
    this.onKeyDown = (event) => {
      if (event.repeat) return
      if (event.key.toLowerCase() !== 'd') return
      this.setDebugUiVisible(!this.isDebugUiVisible)
    }

    this.animate = this.update.bind(this)
  }

  async init() {
    if (this.isInitialized) return

    document.body.classList.add('loading')

    try {
      this.preloadedTextures = await this.preloadTextures()
      this.experience.gallery.setPreloadedTextures(this.preloadedTextures)

      await this.experience.init(this.scene, this.camera)
      this.scroll.init()
      this.initStats()
      this.bindDebug()
      this.setDebugUiVisible(false)

      this.resize()
      window.addEventListener('resize', this.onResize)
      window.addEventListener('keydown', this.onKeyDown)
      this.scroll.bindEvents()

      this.isInitialized = true
      this.start()
    } finally {
      document.body.classList.remove('loading')
    }
  }

  start() {
    if (!this.isInitialized || this.isRunning) return

    this.isRunning = true
    this.update()
  }

  resize() {
    const width = this.canvas.clientWidth || window.innerWidth || 1
    const height = this.canvas.clientHeight || window.innerHeight || 1
    if (width <= 0 || height <= 0) return

    this.camera.aspect = width / height
    this.camera.updateProjectionMatrix()
    this.renderer.setSize(width, height, false)
    this.experience.gallery.updatePlaneScale()
    this.experience.gallery.layoutPlanes()
    this.experience.label.resize(width, height)
  }

  async preloadTextures() {
    const textureSources = this.experience.gallery.getTextureSources()
    if (!textureSources.length) return new Map()

    const textureLoader = new THREE.TextureLoader()
    const loadedTextures = new Map()

    await Promise.all(
      textureSources.map(async (textureSource) => {
        try {
          const texture = await textureLoader.loadAsync(textureSource)
          texture.colorSpace = THREE.SRGBColorSpace
          loadedTextures.set(textureSource, texture)
        } catch (error) {
          console.warn(`Texture failed to load: ${textureSource}`, error)
        }
      })
    )

    return loadedTextures
  }

  update() {
    if (!this.isRunning) return

    this.animationFrameRequestId = requestAnimationFrame(this.animate)
    this.stats?.begin()

    const time = performance.now()

    this.scroll.update()
    this.experience.update(time, this.camera, this.scroll)

    this.renderer.clear(true, true, true)
    this.experience.background.render(this.renderer)
    this.renderer.clearDepth()
    this.renderer.render(this.scene, this.camera)
    this.experience.label.render()
    this.stats?.end()
  }

  initStats() {
    if (this.stats) return

    this.stats = new Stats()
    this.stats.showPanel(0)
    this.stats.dom.classList.add('fps-stats')
    document.body.append(this.stats.dom)
    this.setFpsVisible(this.showFps)
  }

  setFpsVisible(isVisible) {
    if (!this.stats) return
    const shouldShow = Boolean(isVisible) && this.isDebugUiVisible
    this.stats.dom.style.display = shouldShow ? 'block' : 'none'
  }

  setDebugUiVisible(isVisible) {
    this.isDebugUiVisible = Boolean(isVisible)
    this.debug?.setVisible(this.isDebugUiVisible)
    this.scroll?.setDebugUiVisible(this.isDebugUiVisible)
    this.setFpsVisible(this.showFps)
  }

  bindDebug() {
    if (!this.debug || this.isDebugBound) return

    this.debug.addBinding({
      folderTitle: 'Engine',
      targetObject: this,
      property: 'showFps',
      label: 'Show FPS',
      onChange: (value) => {
        this.setFpsVisible(value)
      },
    })

    this.isDebugBound = true
  }

  dispose() {
    this.isRunning = false

    if (this.animationFrameRequestId !== null) {
      cancelAnimationFrame(this.animationFrameRequestId)
      this.animationFrameRequestId = null
    }

    window.removeEventListener('resize', this.onResize)
    window.removeEventListener('keydown', this.onKeyDown)
    this.scroll.dispose()

    this.preloadedTextures.forEach((texture) => {
      texture.dispose()
    })
    this.preloadedTextures.clear()
    this.stats?.dom.remove()
    this.stats = null
    this.experience.dispose?.()
  }
}

export { Engine }
src/Experience/Gallery.js
파일 저장

import * as THREE from 'three'
import { galleryPlaneData } from '@/data/galleryData'

class Gallery {
  constructor(debug = null) {
    this.isInitialized = false
    this.isDebugBound = false
    this.debug = debug

    // Planes
    this.planes = []
    this.texturesBySource = new Map()
    this.useTextures = true
    this.planeGap = 5
    this.desktopPlaneScale = 1
    this.mobilePlaneScale = 0.65
    this.mobileXSpreadFactor = 0.25
    this.mobileBreakpoint = 768
    this.planeConfig = galleryPlaneData
    this.moodSampleOffset = 1
    this.planeFadeSampleOffset = 1
    this.planeFadeSmoothing = 0.14

    // Parallax
    this.parallaxEnabled = true
    this.parallaxAmountX = 0.16
    this.parallaxAmountY = 0.08
    this.parallaxSmoothing = 0.08
    this.pointerTarget = new THREE.Vector2(0, 0)
    this.pointerCurrent = new THREE.Vector2(0, 0)

    // Breath
    this.breathEnabled = true
    this.breathTiltAmount = 0.045
    this.breathScaleAmount = 0.03
    this.breathSmoothing = 0.14
    this.breathGain = 1.1
    this.breathIntensity = 0
    this.targetBreathIntensity = 0

    // Gesture drift
    this.gestureParallaxEnabled = true
    this.gestureParallaxAmountY = 0.05
    this.gestureParallaxSmoothing = 0.05
    this.driftCurrent = 0
    this.driftTarget = 0

    // Pointer events
    this.onPointerMove = (event) => {
      const x = (event.clientX / window.innerWidth) * 2 - 1
      const y = (event.clientY / window.innerHeight) * 2 - 1
      this.pointerTarget.set(x, -y)
    }
    this.onPointerLeave = () => {
      this.pointerTarget.set(0, 0)
    }
  }

  async init(scene) {
    if (this.isInitialized) return

    this.setPlanes(scene)
    this.updatePlaneMaterialMode()
    this.updatePlaneScale()
    this.layoutPlanes()
    this.bindPointerEvents()
    this.bindDebug()

    this.isInitialized = true
  }

  setPlanes(scene) {
    const planeGeometry = new THREE.PlaneGeometry(3, 3)

    this.planeConfig.forEach((plane, index) => {
      const texture = this.texturesBySource.get(plane.textureSrc) || null
      const textureImage = texture?.image
      const aspectRatio =
        textureImage && textureImage.width > 0 && textureImage.height > 0
          ? textureImage.width / textureImage.height
          : 1
      const fallbackColor = plane.fallbackColor || '#ffffff'
      const accentColor = plane.accentColor || fallbackColor
      const backgroundColor = plane.backgroundColor || fallbackColor
      const blob1Color = plane.blob1Color || fallbackColor
      const blob2Color = plane.blob2Color || fallbackColor
      const labelData = this.getPlaneLabelData(plane, this.planes.length)
      const planeMaterial = new THREE.MeshBasicMaterial({
        color: fallbackColor,
        map: texture,
        side: THREE.DoubleSide,
        transparent: true,
        depthWrite: false,
        opacity: index === 0 ? 1 : 0,
      })
      const planeMesh = new THREE.Mesh(planeGeometry, planeMaterial)
      planeMesh.userData.basePosition = plane.position
      planeMesh.userData.baseColor = fallbackColor
      planeMesh.userData.accentColor = accentColor
      planeMesh.userData.backgroundColor = backgroundColor
      planeMesh.userData.blob1Color = blob1Color
      planeMesh.userData.blob2Color = blob2Color
      planeMesh.userData.label = labelData
      planeMesh.userData.texture = texture
      planeMesh.userData.aspectRatio = aspectRatio
      scene.add(planeMesh)
      this.planes.push(planeMesh)
    })
  }

  getPlaneLabelData(planeDefinition, index) {
    const fallback = {
      word: `tone ${String(index + 1).padStart(2, '0')}`,
      pms: 'N/A',
      color: '',
    }
    const label = planeDefinition.label || fallback

    return {
      word: label.word || fallback.word,
      pms: label.pms || fallback.pms,
      color: label.color || fallback.color,
    }
  }

  updatePlaneScale() {
    const isMobileViewport = window.innerWidth <= this.mobileBreakpoint
    const scale = isMobileViewport ? this.mobilePlaneScale : this.desktopPlaneScale

    this.planes.forEach((plane) => {
      const aspectRatio = plane.userData.aspectRatio || 1
      plane.scale.set(scale * aspectRatio, scale, 1)
    })
  }

  layoutPlanes() {
    const xSpreadFactor = this.getXSpreadFactor()

    this.planes.forEach((plane, index) => {
      const basePosition = plane.userData.basePosition || { x: 0, y: 0 }
      const xPosition = basePosition.x * xSpreadFactor
      plane.position.set(xPosition, basePosition.y, -index * this.planeGap)
    })
  }

  getXSpreadFactor() {
    const isMobileViewport = window.innerWidth <= this.mobileBreakpoint
    return isMobileViewport ? this.mobileXSpreadFactor : 1
  }

  getDepthRange() {
    if (!this.planes.length) {
      return { nearestZ: 0, deepestZ: 0 }
    }

    const zPositions = this.planes.map((plane) => plane.position.z)
    return {
      nearestZ: Math.max(...zPositions),
      deepestZ: Math.min(...zPositions),
    }
  }

  getDepthProgress(cameraZ) {
    const { nearestZ, deepestZ } = this.getDepthRange()
    const depthSpan = nearestZ - deepestZ
    if (depthSpan <= 0) return 0

    return THREE.MathUtils.clamp((nearestZ - cameraZ) / depthSpan, 0, 1)
  }

  getActivePlaneIndex(cameraZ) {
    if (!this.planes.length) return -1

    let closestPlaneIndex = 0
    let smallestDistance = Infinity

    this.planes.forEach((plane, index) => {
      const distanceToPlane = Math.abs(cameraZ - plane.position.z)
      if (distanceToPlane < smallestDistance) {
        smallestDistance = distanceToPlane
        closestPlaneIndex = index
      }
    })

    return closestPlaneIndex
  }

  getMoodColorsByIndex(index) {
    if (index < 0 || index >= this.planes.length) return null

    const { backgroundColor, blob1Color, blob2Color } = this.planes[index].userData
    if (!backgroundColor) return null

    return { background: backgroundColor, blob1: blob1Color, blob2: blob2Color }
  }

  getMoodBlendData(cameraZ) {
    if (!this.planes.length) return null

    const safeCameraZ = Number.isFinite(cameraZ) ? cameraZ : this.planes[0].position.z
    const moodSampleZ = safeCameraZ - this.planeGap * this.moodSampleOffset
    const lastPlaneIndex = this.planes.length - 1

    if (lastPlaneIndex === 0 || this.planeGap <= 0) {
      const singleMood = this.getMoodColorsByIndex(0)
      if (!singleMood) return null

      return {
        currentMood: singleMood,
        nextMood: singleMood,
        blend: 0,
      }
    }

    const firstPlaneZ = this.planes[0].position.z
    const normalizedDepth = THREE.MathUtils.clamp(
      (firstPlaneZ - moodSampleZ) / this.planeGap,
      0,
      lastPlaneIndex
    )
    const currentPlaneIndex = Math.floor(normalizedDepth)
    const nextPlaneIndex = Math.min(currentPlaneIndex + 1, lastPlaneIndex)
    const blend = normalizedDepth - currentPlaneIndex

    const currentMood = this.getMoodColorsByIndex(currentPlaneIndex)
    const nextMood = this.getMoodColorsByIndex(nextPlaneIndex) || currentMood
    if (!currentMood || !nextMood) return null

    return {
      currentMood,
      nextMood,
      blend,
    }
  }

  getPlaneBlendData(cameraZ) {
    if (!this.planes.length) return null

    const planeGap = Math.max(this.planeGap, 0.0001)
    const firstPlaneZ = this.planes[0].position.z
    const lastPlaneIndex = this.planes.length - 1
    const sampledCameraZ = cameraZ - planeGap * this.planeFadeSampleOffset
    const normalizedDepth = THREE.MathUtils.clamp(
      (firstPlaneZ - sampledCameraZ) / planeGap,
      0,
      lastPlaneIndex
    )
    const currentPlaneIndex = Math.floor(normalizedDepth)
    const nextPlaneIndex = Math.min(currentPlaneIndex + 1, lastPlaneIndex)
    const blend = normalizedDepth - currentPlaneIndex

    return {
      currentPlaneIndex,
      nextPlaneIndex,
      blend,
    }
  }

  getActiveMoodColors(cameraZ) {
    const moodBlendData = this.getMoodBlendData(cameraZ)
    return moodBlendData?.currentMood || null
  }

  getTextureSources() {
    const textureSources = this.planeConfig
      .map((planeDefinition) => planeDefinition.textureSrc)
      .filter(Boolean)

    return [...new Set(textureSources)]
  }

  setPreloadedTextures(texturesBySource) {
    this.texturesBySource = texturesBySource instanceof Map ? texturesBySource : new Map()
  }

  updatePlaneMaterialMode() {
    this.planes.forEach((plane) => {
      const planeMaterial = plane.material
      const texture = plane.userData.texture || null
      const hasTexture = Boolean(texture)

      planeMaterial.map = this.useTextures && hasTexture ? texture : null
      planeMaterial.color.set(this.useTextures && hasTexture ? '#ffffff' : plane.userData.baseColor)
      planeMaterial.needsUpdate = true
    })
  }

  bindDebug() {
    if (!this.debug || this.isDebugBound) return

    this.debug.addBinding({
      folderTitle: 'Gallery',
      targetObject: this,
      property: 'planeGap',
      label: 'Gap',
      options: {
        min: 0.4,
        max: 10,
        step: 0.1,
      },
      onChange: () => {
        this.layoutPlanes()
      },
    })

    this.debug.addBinding({
      folderTitle: 'Gallery',
      targetObject: this,
      property: 'useTextures',
      label: 'Use Textures',
      onChange: () => {
        this.updatePlaneMaterialMode()
      },
    })

    this.debug.addBinding({
      folderTitle: 'Gallery',
      targetObject: this,
      property: 'moodSampleOffset',
      label: 'Mood Offset',
      options: {
        min: 0,
        max: 1.5,
        step: 0.01,
      },
    })

    this.debug.addBinding({
      folderTitle: 'Gallery',
      targetObject: this,
      property: 'parallaxEnabled',
      label: 'Plane Parallax',
    })

    this.debug.addBinding({
      folderTitle: 'Gallery',
      targetObject: this,
      property: 'parallaxAmountX',
      label: 'Parallax X',
      options: {
        min: 0,
        max: 0.5,
        step: 0.01,
      },
    })

    this.debug.addBinding({
      folderTitle: 'Gallery',
      targetObject: this,
      property: 'parallaxAmountY',
      label: 'Parallax Y',
      options: {
        min: 0,
        max: 0.3,
        step: 0.01,
      },
    })

    this.debug.addBinding({
      folderTitle: 'Gallery',
      targetObject: this,
      property: 'gestureParallaxEnabled',
      label: 'Gesture Parallax',
    })

    this.debug.addBinding({
      folderTitle: 'Gallery',
      targetObject: this,
      property: 'gestureParallaxAmountY',
      label: 'Gesture Y',
      options: {
        min: 0,
        max: 0.5,
        step: 0.01,
      },
    })

    this.debug.addBinding({
      folderTitle: 'Gallery',
      targetObject: this,
      property: 'breathEnabled',
      label: 'Plane Breath',
    })

    this.debug.addBinding({
      folderTitle: 'Gallery',
      targetObject: this,
      property: 'breathTiltAmount',
      label: 'Breath Tilt',
      options: {
        min: 0,
        max: 0.2,
        step: 0.005,
      },
    })

    this.debug.addBinding({
      folderTitle: 'Gallery',
      targetObject: this,
      property: 'breathScaleAmount',
      label: 'Breath Scale',
      options: {
        min: 0,
        max: 0.1,
        step: 0.001,
      },
    })

    this.isDebugBound = true
  }

  updatePlaneVisibility(cameraZ) {
    const blendData = this.getPlaneBlendData(cameraZ)
    if (!blendData) return

    const { currentPlaneIndex, nextPlaneIndex, blend } = blendData

    this.planes.forEach((plane, index) => {
      let targetOpacity = 0

      if (index === currentPlaneIndex) {
        targetOpacity = 1 - blend
      }
      if (index === nextPlaneIndex) {
        targetOpacity = Math.max(targetOpacity, blend)
      }

      const currentOpacity = Number.isFinite(plane.material.opacity) ? plane.material.opacity : 0
      plane.material.opacity = THREE.MathUtils.lerp(
        currentOpacity,
        targetOpacity,
        this.planeFadeSmoothing
      )
      plane.material.needsUpdate = true
    })
  }

  bindPointerEvents() {
    window.addEventListener('pointermove', this.onPointerMove, { passive: true })
    window.addEventListener('pointerleave', this.onPointerLeave, { passive: true })
  }

  updatePlaneMotion(scroll = null) {
    // Smooth pointer toward target
    this.pointerCurrent.lerp(this.pointerTarget, this.parallaxSmoothing)

    // Velocity → breath + drift
    const velocityMax = Math.max(scroll?.velocityMax || 1, 0.0001)
    const velocityNormalized = THREE.MathUtils.clamp(
      Math.abs(scroll?.velocity || 0) / velocityMax,
      0,
      1
    )
    const scrollDrift = THREE.MathUtils.clamp((scroll?.velocity || 0) / velocityMax, -1, 1)
    this.targetBreathIntensity = this.breathEnabled
      ? THREE.MathUtils.clamp(velocityNormalized * this.breathGain, 0, 1)
      : 0
    this.breathIntensity = THREE.MathUtils.lerp(
      this.breathIntensity,
      this.targetBreathIntensity,
      this.breathSmoothing
    )
    this.driftTarget = this.gestureParallaxEnabled ? scrollDrift : 0
    this.driftCurrent = THREE.MathUtils.lerp(
      this.driftCurrent,
      this.driftTarget,
      this.gestureParallaxSmoothing
    )

    // Per-plane: position, rotation, scale
    const xSpreadFactor = this.getXSpreadFactor()

    this.planes.forEach((plane, index) => {
      const basePosition = plane.userData.basePosition || { x: 0, y: 0 }
      const xPosition = basePosition.x * xSpreadFactor
      const yPosition = basePosition.y
      const zPosition = -index * this.planeGap
      const opacity = Number.isFinite(plane.material.opacity) ? plane.material.opacity : 0
      const depthInfluence = 1 + index * 0.05
      const parallaxInfluence = this.parallaxEnabled ? opacity * depthInfluence : 0

      const parallaxOffsetX = this.pointerCurrent.x * this.parallaxAmountX * parallaxInfluence
      const parallaxOffsetY = this.pointerCurrent.y * this.parallaxAmountY * parallaxInfluence
      const gestureOffsetY = this.driftCurrent * this.gestureParallaxAmountY

      plane.position.x = xPosition + parallaxOffsetX
      plane.position.y = yPosition + parallaxOffsetY + gestureOffsetY
      plane.position.z = zPosition

      const breathInfluence = this.breathEnabled ? this.breathIntensity * opacity : 0
      const tiltX = -this.pointerCurrent.y * this.breathTiltAmount * breathInfluence
      const tiltY = this.pointerCurrent.x * this.breathTiltAmount * breathInfluence
      plane.rotation.x = tiltX
      plane.rotation.y = tiltY
      plane.rotation.z = 0

      const aspectRatio = plane.userData.aspectRatio || 1
      const baseScale =
        window.innerWidth <= this.mobileBreakpoint ? this.mobilePlaneScale : this.desktopPlaneScale
      const scalePulse = 1 + this.breathScaleAmount * breathInfluence
      plane.scale.x = baseScale * aspectRatio * scalePulse
      plane.scale.y = baseScale * scalePulse
      plane.scale.z = 1
    })
  }

  update(camera = null, scroll = null) {
    if (!camera) return
    this.updatePlaneVisibility(camera.position.z)
    this.updatePlaneMotion(scroll)
  }

  dispose() {
    window.removeEventListener('pointermove', this.onPointerMove)
    window.removeEventListener('pointerleave', this.onPointerLeave)
  }
}

export { Gallery }
src/Experience/Label.js
파일 저장

class Label {
  constructor(gallery) {
    this.gallery = gallery

    this.overlayElement = null
    this.leftIndexElement = null
    this.wordElement = null
    this.chipElement = null
    this.cmykValueElement = null
    this.rgbValueElement = null
    this.hexValueElement = null
    this.pmsValueElement = null
    this.activePlaneIndex = -1
  }

  createElement() {
    const element = document.createElement('section')
    element.className = 'plane-label-overlay'
    element.innerHTML = `
      <div class="plane-label-overlay__left">
        <p class="plane-label-overlay__index"></p>
        <p class="plane-label-card__word"></p>
        <span class="plane-label-overlay__chip"></span>
      </div>
      <article class="plane-label-card plane-label-overlay__right">
        <dl class="plane-label-card__specs">
          <div class="plane-label-card__row">
            <dt>CMYK</dt>
            <dd class="plane-label-card__value plane-label-card__value--cmyk"></dd>
          </div>
          <div class="plane-label-card__row">
            <dt>RGB</dt>
            <dd class="plane-label-card__value plane-label-card__value--rgb"></dd>
          </div>
          <div class="plane-label-card__row">
            <dt>HEX</dt>
            <dd class="plane-label-card__value plane-label-card__value--hex"></dd>
          </div>
          <div class="plane-label-card__row">
            <dt>PMS</dt>
            <dd class="plane-label-card__value plane-label-card__value--pms"></dd>
          </div>
        </dl>
      </article>
    `

    return {
      element,
      leftIndexElement: element.querySelector('.plane-label-overlay__index'),
      wordElement: element.querySelector('.plane-label-card__word'),
      chipElement: element.querySelector('.plane-label-overlay__chip'),
      cmykValueElement: element.querySelector('.plane-label-card__value--cmyk'),
      rgbValueElement: element.querySelector('.plane-label-card__value--rgb'),
      hexValueElement: element.querySelector('.plane-label-card__value--hex'),
      pmsValueElement: element.querySelector('.plane-label-card__value--pms'),
    }
  }

  init() {
    if (this.overlayElement) return

    const {
      element,
      leftIndexElement,
      wordElement,
      chipElement,
      cmykValueElement,
      rgbValueElement,
      hexValueElement,
      pmsValueElement,
    } = this.createElement()

    this.overlayElement = element
    this.leftIndexElement = leftIndexElement
    this.wordElement = wordElement
    this.chipElement = chipElement
    this.cmykValueElement = cmykValueElement
    this.rgbValueElement = rgbValueElement
    this.hexValueElement = hexValueElement
    this.pmsValueElement = pmsValueElement
    this.overlayElement.style.opacity = '0'

    document.body.append(this.overlayElement)
  }

  normalizeHexColor(rawColor) {
    const fallbackColor = '#ffffff'
    if (typeof rawColor !== 'string') return fallbackColor

    let hexColor = rawColor.trim()
    if (!hexColor) return fallbackColor
    if (!hexColor.startsWith('#')) {
      hexColor = `#${hexColor}`
    }

    if (/^#[0-9a-fA-F]{3}$/.test(hexColor)) {
      const shortHex = hexColor.slice(1)
      hexColor = `#${shortHex
        .split('')
        .map((character) => `${character}${character}`)
        .join('')}`
    }

    if (!/^#[0-9a-fA-F]{6}$/.test(hexColor)) return fallbackColor
    return hexColor.toLowerCase()
  }

  hexToRgb(hexColor) {
    const normalizedColor = this.normalizeHexColor(hexColor).slice(1)
    const red = Number.parseInt(normalizedColor.slice(0, 2), 16)
    const green = Number.parseInt(normalizedColor.slice(2, 4), 16)
    const blue = Number.parseInt(normalizedColor.slice(4, 6), 16)

    return {
      r: red,
      g: green,
      b: blue,
    }
  }

  rgbToCmyk({ r, g, b }) {
    const red = r / 255
    const green = g / 255
    const blue = b / 255
    const black = 1 - Math.max(red, green, blue)

    if (black >= 0.999) {
      return { c: 0, m: 0, y: 0, k: 100 }
    }

    const cyan = ((1 - red - black) / (1 - black)) * 100
    const magenta = ((1 - green - black) / (1 - black)) * 100
    const yellow = ((1 - blue - black) / (1 - black)) * 100

    return {
      c: Math.round(cyan),
      m: Math.round(magenta),
      y: Math.round(yellow),
      k: Math.round(black * 100),
    }
  }

  buildColorSpecs(accentColor, pmsValue) {
    const normalizedAccentColor = this.normalizeHexColor(accentColor)
    const rgb = this.hexToRgb(normalizedAccentColor)
    const cmyk = this.rgbToCmyk(rgb)

    return {
      chipHex: normalizedAccentColor,
      cmyk: `${cmyk.c}, ${cmyk.m}, ${cmyk.y}, ${cmyk.k}`,
      rgb: `${rgb.r}, ${rgb.g}, ${rgb.b}`,
      hex: normalizedAccentColor.slice(1).toUpperCase(),
      pms: pmsValue || 'N/A',
    }
  }

  getTargetPlaneIndex(cameraZ) {
    const blendData = this.gallery.getPlaneBlendData(cameraZ)
    if (!blendData) return -1
    return blendData.blend >= 0.5 ? blendData.nextPlaneIndex : blendData.currentPlaneIndex
  }

  applyPlaneContent(planeIndex) {
    const plane = this.gallery.planes[planeIndex]
    if (!plane || this.activePlaneIndex === planeIndex) return

    const labelData = plane.userData.label || {}
    const colorSpecs = this.buildColorSpecs(plane.userData.accentColor, labelData.pms)

    this.leftIndexElement.textContent = String(planeIndex + 1).padStart(2, '0')
    this.wordElement.textContent = labelData.word || 'tone'
    this.chipElement.style.backgroundColor = colorSpecs.chipHex
    this.cmykValueElement.textContent = colorSpecs.cmyk
    this.rgbValueElement.textContent = colorSpecs.rgb
    this.hexValueElement.textContent = colorSpecs.hex
    this.pmsValueElement.textContent = colorSpecs.pms
    this.overlayElement.style.color = labelData.color || ''

    this.activePlaneIndex = planeIndex
  }

  resize() {}

  update(camera = null) {
    if (!camera || !this.overlayElement) return

    const targetPlaneIndex = this.getTargetPlaneIndex(camera.position.z)
    if (targetPlaneIndex < 0) {
      this.overlayElement.style.opacity = '0'
      return
    }

    this.applyPlaneContent(targetPlaneIndex)
    this.overlayElement.style.opacity = '1'
  }

  render() {}

  dispose() {
    this.overlayElement?.remove()
    this.overlayElement = null
    this.leftIndexElement = null
    this.wordElement = null
    this.chipElement = null
    this.cmykValueElement = null
    this.rgbValueElement = null
    this.hexValueElement = null
    this.pmsValueElement = null
    this.activePlaneIndex = -1
  }
}

export { Label }
src/Experience/Scroll.js
파일 저장

import * as THREE from 'three'

class Scroll {
  constructor(camera, gallery, debug = null) {
    this.isInitialized = false
    this.isDebugBound = false
    this.camera = camera
    this.gallery = gallery
    this.debug = debug

    // Scroll state
    this.scrollTarget = 0
    this.scrollCurrent = 0
    this.scrollSmoothing = 0.08
    this.scrollToWorldFactor = 0.01
    this.wheelScrollSpeed = 1
    this.touchScrollSpeed = 1.8
    this.previousScrollCurrent = 0
    this.invertScroll = false

    // Velocity
    this.rawVelocity = 0
    this.velocity = 0
    this.velocityDamping = 0.12
    this.velocityMax = 1.5
    this.velocityStopThreshold = 0.0001

    // Bounds
    this.useScrollBounds = true
    this.firstPlaneViewOffset = 5
    this.lastPlaneViewOffset = 5
    this.minCameraZ = -Infinity
    this.maxCameraZ = Infinity
    this.cameraStartZ = this.camera.position.z

    // Debug UI
    this.showVelocityVisualizer = true
    this.debugUiVisible = false
    this.touchY = 0
    this.velocityVisualizerElement = null
    this.velocityVisualizerFillElement = null
    this.velocityVisualizerValueElement = null

    // Input events
    this.onWheel = (event) => {
      event.preventDefault()
      const normalizedWheelDelta = this.normalizeWheelDelta(event) * this.wheelScrollSpeed
      this.addScrollInput(normalizedWheelDelta)
    }
    this.onTouchStart = (event) => {
      this.touchY = event.touches[0]?.clientY ?? 0
    }
    this.onTouchMove = (event) => {
      event.preventDefault()
      const currentTouchY = event.touches[0]?.clientY ?? this.touchY
      const deltaY = this.touchY - currentTouchY
      this.addScrollInput(deltaY * this.touchScrollSpeed)
      this.touchY = currentTouchY
    }
  }

  init() {
    if (this.isInitialized) return

    this.updateCameraBounds()
    this.cameraStartZ = this.maxCameraZ
    this.camera.position.z = this.cameraStartZ
    this.scrollTarget = 0
    this.scrollCurrent = 0
    this.previousScrollCurrent = this.scrollCurrent
    this.rawVelocity = 0
    this.velocity = 0
    this.createVelocityVisualizer()
    this.updateVelocityVisualizer()
    this.bindDebug()

    this.isInitialized = true
  }

  bindEvents() {
    window.addEventListener('wheel', this.onWheel, { passive: false })
    window.addEventListener('touchstart', this.onTouchStart, { passive: true })
    window.addEventListener('touchmove', this.onTouchMove, { passive: false })
  }

  updateCameraBounds() {
    const depthRange = this.gallery.getDepthRange()
    this.maxCameraZ = depthRange.nearestZ + this.firstPlaneViewOffset
    this.minCameraZ = depthRange.deepestZ + this.lastPlaneViewOffset

    if (this.minCameraZ > this.maxCameraZ) {
      this.minCameraZ = this.maxCameraZ
    }
  }

  cameraZFromScroll(scrollAmount) {
    return this.cameraStartZ - scrollAmount * this.scrollToWorldFactor
  }

  scrollFromCameraZ(cameraZ) {
    if (this.scrollToWorldFactor === 0) return 0
    return (this.cameraStartZ - cameraZ) / this.scrollToWorldFactor
  }

  normalizeWheelDelta(event) {
    if (event.deltaMode === 1) return event.deltaY * 16
    if (event.deltaMode === 2) return event.deltaY * window.innerHeight
    return event.deltaY
  }

  addScrollInput(deltaY) {
    const scrollDirection = this.invertScroll ? -1 : 1
    this.scrollTarget += deltaY * scrollDirection
  }

  updateVelocity() {
    this.rawVelocity = this.scrollCurrent - this.previousScrollCurrent
    this.velocity = THREE.MathUtils.lerp(this.velocity, this.rawVelocity, this.velocityDamping)
    this.velocity = THREE.MathUtils.clamp(this.velocity, -this.velocityMax, this.velocityMax)

    if (Math.abs(this.velocity) < this.velocityStopThreshold) {
      this.velocity = 0
    }

    this.previousScrollCurrent = this.scrollCurrent
  }

  createVelocityVisualizer() {
    if (this.velocityVisualizerElement) return

    const container = document.createElement('div')
    container.className = 'velocity-visualizer'

    const label = document.createElement('p')
    label.className = 'velocity-visualizer__label'
    label.textContent = 'Velocity'

    const value = document.createElement('p')
    value.className = 'velocity-visualizer__value'
    value.textContent = '0.0000'

    const track = document.createElement('div')
    track.className = 'velocity-visualizer__track'

    const fill = document.createElement('div')
    fill.className = 'velocity-visualizer__fill'
    track.append(fill)

    container.append(label, value, track)
    document.body.append(container)

    this.velocityVisualizerElement = container
    this.velocityVisualizerFillElement = fill
    this.velocityVisualizerValueElement = value
    this.setVelocityVisualizerVisible(this.showVelocityVisualizer)
  }

  setVelocityVisualizerVisible(isVisible) {
    if (!this.velocityVisualizerElement) return
    const shouldShow = Boolean(isVisible) && this.debugUiVisible
    this.velocityVisualizerElement.style.display = shouldShow ? 'block' : 'none'
  }

  setDebugUiVisible(isVisible) {
    this.debugUiVisible = Boolean(isVisible)
    this.setVelocityVisualizerVisible(this.showVelocityVisualizer)
  }

  updateVelocityVisualizer() {
    if (
      !this.velocityVisualizerElement ||
      !this.velocityVisualizerFillElement ||
      !this.velocityVisualizerValueElement
    ) {
      return
    }

    const velocitySign = this.velocity === 0 ? 0 : Math.sign(this.velocity)
    const normalizedVelocity = THREE.MathUtils.clamp(
      Math.abs(this.velocity) / this.velocityMax,
      0,
      1
    )
    const fillPercent = normalizedVelocity * 50

    if (velocitySign >= 0) {
      this.velocityVisualizerFillElement.style.left = '50%'
      this.velocityVisualizerFillElement.style.width = `${fillPercent}%`
    } else {
      this.velocityVisualizerFillElement.style.left = `${50 - fillPercent}%`
      this.velocityVisualizerFillElement.style.width = `${fillPercent}%`
    }

    this.velocityVisualizerFillElement.style.backgroundColor =
      velocitySign >= 0 ? '#7fffd4' : '#ff8fab'
    this.velocityVisualizerValueElement.textContent = this.velocity.toFixed(4)
  }

  update() {
    this.updateCameraBounds()
    this.scrollCurrent = THREE.MathUtils.lerp(
      this.scrollCurrent,
      this.scrollTarget,
      this.scrollSmoothing
    )

    if (this.useScrollBounds) {
      const minimumScroll = this.scrollFromCameraZ(this.maxCameraZ)
      const maximumScroll = this.scrollFromCameraZ(this.minCameraZ)

      this.scrollTarget = THREE.MathUtils.clamp(this.scrollTarget, minimumScroll, maximumScroll)
      this.scrollCurrent = THREE.MathUtils.clamp(this.scrollCurrent, minimumScroll, maximumScroll)
    }

    this.updateVelocity()
    this.updateVelocityVisualizer()

    const nextCameraZ = this.cameraZFromScroll(this.scrollCurrent)
    if (this.useScrollBounds) {
      this.camera.position.z = THREE.MathUtils.clamp(nextCameraZ, this.minCameraZ, this.maxCameraZ)
      return
    }

    this.camera.position.z = nextCameraZ
  }

  bindDebug() {
    if (!this.debug || this.isDebugBound) return

    this.debug.addBinding({
      folderTitle: 'Scroll',
      targetObject: this,
      property: 'useScrollBounds',
      label: 'Use Bounds',
    })

    this.debug.addBinding({
      folderTitle: 'Scroll',
      targetObject: this,
      property: 'invertScroll',
      label: 'Invert Scroll',
    })

    this.debug.addBinding({
      folderTitle: 'Scroll',
      targetObject: this,
      property: 'showVelocityVisualizer',
      label: 'Debug Velocity',
      onChange: (value) => {
        this.setVelocityVisualizerVisible(value)
      },
    })

    this.debug.addBinding({
      folderTitle: 'Scroll',
      targetObject: this,
      property: 'velocityDamping',
      label: 'Velocity Damping',
      options: {
        min: 0.01,
        max: 1,
        step: 0.01,
      },
    })

    this.debug.addBinding({
      folderTitle: 'Scroll',
      targetObject: this,
      property: 'velocityMax',
      label: 'Velocity Max',
      options: {
        min: 0.1,
        max: 5,
        step: 0.1,
      },
    })

    this.isDebugBound = true
  }

  dispose() {
    window.removeEventListener('wheel', this.onWheel)
    window.removeEventListener('touchstart', this.onTouchStart)
    window.removeEventListener('touchmove', this.onTouchMove)

    if (this.velocityVisualizerElement) {
      this.velocityVisualizerElement.remove()
    }
    this.velocityVisualizerElement = null
    this.velocityVisualizerFillElement = null
    this.velocityVisualizerValueElement = null
  }
}

export { Scroll }
src/Experience/Trail.js
파일 저장

import * as THREE from 'three'

class Trail {
  constructor() {
    this.group = new THREE.Group()
    this.points = []
    this.mesh = null

    this.minDistance = 0.006 // Minimum movement before adding a point
    this.maxPoints = 220 // Maximum points kept in the trail
    this.curveTension = 0.5 // Curviness of Catmull-Rom spline
    this.curveSegments = 220 // Max tube segments along the curve
    this.radialSegments = 8 // Tube roundness (sides around)
    this.radiusHead = 0.012 // Radius near latest point
    this.radiusTail = 0.003 // Radius near oldest point
    this.pointSmoothing = 0.3 // Lerp smoothing when adding points
    this.maxTrimPerFrame = 4 // Max point removals per frame
    this.jumpResetDistance = 999 // Hard reset threshold for huge jumps

    this.material = new THREE.MeshStandardMaterial({
      color: new THREE.Color('#f6f9ff'),
      emissive: new THREE.Color('#7fd5ff'),
      emissiveIntensity: 1.35,
      roughness: 0.2,
      metalness: 0.05,
      transparent: true,
      opacity: 0.84,
      depthWrite: false,
      depthTest: false,
      blending: THREE.NormalBlending,
    })
  }

  get object() {
    return this.group
  }

  addPoint(position) {
    if (!(position instanceof THREE.Vector3)) return

    const lastPoint = this.points[this.points.length - 1] || null // Current trail tip

    if (lastPoint && position.distanceToSquared(lastPoint) < this.minDistance * this.minDistance) {
      return
    }

    const nextPoint = position.clone() // Safe copy of incoming point

    // On large jumps, restart the trail so it snaps cleanly.
    if (lastPoint && nextPoint.distanceTo(lastPoint) > this.jumpResetDistance) {
      this.points = [nextPoint]

      if (this.mesh) {
        this.mesh.geometry.dispose()
        this.group.remove(this.mesh)
        this.mesh = null
      }

      return
    }

    const easedPoint = lastPoint
      ? lastPoint.clone().lerp(nextPoint, this.pointSmoothing)
      : nextPoint
    this.points.push(easedPoint)

    let trimBudget = this.maxTrimPerFrame // Removal budget this frame
    while (this.points.length > this.maxPoints && trimBudget > 0) {
      this.points.shift()
      trimBudget -= 1
    }

    if (this.points.length < 2) {
      return
    }

    const curve = new THREE.CatmullRomCurve3(this.points, false, 'centripetal', this.curveTension)
    const segments = Math.max(24, Math.min(this.curveSegments, this.points.length * 4))
    const nextGeometry = this.createTaperedTube(curve, segments, this.radiusHead, this.radiusTail)

    if (!this.mesh) {
      this.mesh = new THREE.Mesh(nextGeometry, this.material)
      this.mesh.renderOrder = 1200
      this.group.add(this.mesh)
      return
    }

    this.mesh.geometry.dispose()
    this.mesh.geometry = nextGeometry
  }

  createTaperedTube(curve, segments, radiusHead, radiusTail) {
    const pathPoints = curve.getSpacedPoints(segments) // Points sampled on the curve
    const radialSegments = this.radialSegments // Number of points per ring
    const ringPoints = radialSegments + 1 // Closed ring needs one extra point

    const vertices = []
    const indices = []

    const up = new THREE.Vector3(0, 0, 1) // Reference up direction
    const tangent = new THREE.Vector3() // Forward direction on curve
    const normal = new THREE.Vector3() // Right/side direction on ring
    const binormal = new THREE.Vector3() // Up direction on ring plane
    const radialOffset = new THREE.Vector3() // Offset from path center to ring vertex
    const vertexPosition = new THREE.Vector3() // Final vertex position

    for (let i = 0; i < pathPoints.length; i += 1) {
      const t = i / Math.max(pathPoints.length - 1, 1) // 0..1 position on path
      const radius = radiusHead + (radiusTail - radiusHead) * Math.pow(t, 1.5) // Taper from head to tail

      curve.getTangent(t, tangent).normalize()
      normal.crossVectors(up, tangent).normalize()

      if (normal.lengthSq() === 0) {
        normal.set(1, 0, 0) // Fallback when tangent is parallel to up
      }

      binormal.crossVectors(tangent, normal).normalize()

      for (let j = 0; j <= radialSegments; j += 1) {
        const angle = (j / radialSegments) * Math.PI * 2 // Angle around current ring
        const cx = -Math.cos(angle) * radius // Ring X offset in local frame
        const cy = Math.sin(angle) * radius // Ring Y offset in local frame

        radialOffset.copy(normal).multiplyScalar(cx).addScaledVector(binormal, cy)

        vertexPosition.copy(pathPoints[i]).add(radialOffset)
        vertices.push(vertexPosition.x, vertexPosition.y, vertexPosition.z)
      }
    }

    for (let i = 0; i < pathPoints.length - 1; i += 1) {
      for (let j = 0; j < radialSegments; j += 1) {
        const baseIndex = i * ringPoints + j // Current quad corner index
        indices.push(baseIndex, baseIndex + ringPoints, baseIndex + 1)
        indices.push(baseIndex + ringPoints, baseIndex + ringPoints + 1, baseIndex + 1)
      }
    }

    const geometry = new THREE.BufferGeometry() // Output geometry
    geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3))
    geometry.setIndex(indices)
    geometry.computeVertexNormals()

    return geometry
  }

  dispose() {
    this.reset()
    this.material.dispose()
  }

  reset() {
    if (this.mesh) {
      this.mesh.geometry.dispose()
      this.group.remove(this.mesh)
      this.mesh = null
    }

    this.points = []
  }
}

export { Trail }
src/Experience/TrailController.js
파일 저장

import * as THREE from 'three'
import { Trail } from '@/Experience/Trail'
import { TrailHeadParticles } from '@/Experience/TrailHeadParticles'

const FULL_CIRCLE_RADIANS = Math.PI * 2 // 360 degrees in radians.

class TrailController {
  constructor({ gallery, debug = null }) {
    this.trail = new Trail()
    this.trailHeadParticles = new TrailHeadParticles() // Optional sparkle experiment near trail head.
    this.gallery = gallery
    this.debug = debug
    this.hasBoundDebug = false
    this.trailHeadPosition = new THREE.Vector3() // Reused head position vector
    this.timer = new THREE.Timer()

    this.configuration = {
      isEnabled: true,
      pathSettings: {
        startXPosition: -0.96, // Base X where the path starts
        startYPosition: -1.05, // Base Y where the path starts
        horizontalWidth: 3, // How wide the path goes left/right
        horizontalCycles: 1.85, // Number of horizontal waves
        verticalAmplitude: 0.78, // How much the path moves up/down
        verticalCycles: 2.1, // Number of vertical waves
        distanceAheadOfCamera: 1.65, // Base forward offset from camera
        baseDepthOffset: 4.78, // Base depth subtraction
        depthSpan: 6.52, // Extra depth across full progress
        progressDepthOffset: -0.1, // Bias so trail appears earlier at start
      },
      responsiveSettings: {
        mobileBreakpoint: 768, // Mobile viewport limit in pixels.
        mobileWidthScale: 0.35, // Horizontal width multiplier on mobile.
        mobileStartXOffset: 0.35, // Extra X offset applied on mobile.
      },
      pointSettings: {
        minimumPointCount: 14, // Trail length near start
        maximumPointCount: 220, // Trail length near end
        reverseLengthScale: 0.55, // Shrink length while reversing
        initialSeedPointCount: 10, // Number of startup seed points
        initialSeedStepZ: 0.12, // Z spacing between startup points
        trimPerFrameForward: 4, // Max old points removed per frame (forward)
        trimPerFrameReverse: 32, // Max old points removed per frame (reverse)
      },
      opacitySettings: {
        baseOpacity: 0.51, // Max material opacity
        idleOpacityAtStart: 0.55, // Opacity when user has not moved yet
        idleProgressThreshold: 0.01, // Progress considered as "at start"
        startVisibilityBias: 0.1, // Boost visibility near start
        edgeFadeStart: 0.04, // Edge fade lower bound
        edgeFadeEnd: 0.2, // Edge fade upper bound
        opacitySmoothing: 0.12, // Opacity lerp speed
      },
      visualSettings: {
        trailColor: '#f6f9ff', // Main trail color
        glowColor: '#ffffff', // Emissive color
        glowIntensity: 1.35, // Emissive intensity
        curveTension: 0.67, // Curviness of the generated spline
        pointSmoothing: 0.53, // Smoothing when adding new points
      },
      specialEffectsSettings: {
        showHeadParticles: true, // Quick toggle for the experimental sparkle effect.
      },
      directionChangeEpsilon: 0.0005, // Ignore tiny direction noise
    }

    this.runtimeState = {
      hasSeededInitialPoints: false, // Avoid seeding twice
      hasUserMovedFromStart: false, // Becomes true after first real scroll
      previousProgress: null, // Last frame progress
      previousDirection: 0, // Last non-zero direction (-1 or 1)
      currentOpacity: this.configuration.opacitySettings.baseOpacity, // Smoothed opacity value
    }

    this.applyVisualSettings()
  }

  applyVisualSettings() {
    const { visualSettings, opacitySettings } = this.configuration
    this.trail.material.color.set(visualSettings.trailColor)
    this.trail.material.emissive.set(visualSettings.glowColor)
    this.trail.material.emissiveIntensity = visualSettings.glowIntensity
    this.trail.material.opacity = opacitySettings.baseOpacity
    this.trail.material.needsUpdate = true
    this.trail.curveTension = visualSettings.curveTension
    this.trail.pointSmoothing = visualSettings.pointSmoothing
    this.trailHeadParticles.particles.forEach((particle) => {
      particle.mesh.material.color.set(visualSettings.trailColor)
    })
  }

  init(scene, camera) {
    scene.add(this.trail.object)
    scene.add(this.trailHeadParticles.object)
    this.seedInitialPoints(camera)
    this.bindDebug()
  }

  dispose() {
    this.trail.dispose()
    this.trailHeadParticles.dispose()
    this.runtimeState.hasSeededInitialPoints = false
    this.runtimeState.hasUserMovedFromStart = false
    this.runtimeState.previousProgress = null
    this.runtimeState.previousDirection = 0
    this.hasBoundDebug = false
  }

  update(camera = null, scroll = null, time = null) {
    if (!camera) return
    if (Number.isFinite(time)) {
      this.timer.update(time)
    } else {
      this.timer.update()
    }
    const deltaSeconds = this.timer.getDelta()

    this.trail.object.visible = this.configuration.isEnabled
    const shouldShowHeadParticles =
      this.configuration.isEnabled && this.configuration.specialEffectsSettings.showHeadParticles
    this.trailHeadParticles.setEnabled(shouldShowHeadParticles)
    if (!this.configuration.isEnabled) return

    const currentProgress = this.getProgress(camera, scroll) // Normalized 0..1 progress.
    if (currentProgress > this.configuration.opacitySettings.idleProgressThreshold) {
      this.runtimeState.hasUserMovedFromStart = true
    }

    const currentDirection = this.getDirection(currentProgress) // -1, 0, or 1.
    const hasDirectionReversed =
      currentDirection !== 0 &&
      this.runtimeState.previousDirection !== 0 &&
      currentDirection !== this.runtimeState.previousDirection

    this.updateLength(currentProgress, currentDirection || this.runtimeState.previousDirection)
    const trailHeadPosition = this.computeHeadPosition(camera.position.z, currentProgress)
    this.updateOpacity(currentProgress)

    if (hasDirectionReversed) {
      this.trail.reset()
      const restartLeadPosition = trailHeadPosition.clone() // Small lead point after reset.
      restartLeadPosition.z += currentDirection * this.configuration.pointSettings.initialSeedStepZ
      this.trail.addPoint(restartLeadPosition)
    }

    this.trail.addPoint(trailHeadPosition)

    if (currentDirection !== 0) {
      this.runtimeState.previousDirection = currentDirection
    }
    this.runtimeState.previousProgress = currentProgress

    this.trailHeadParticles.update(
      deltaSeconds,
      trailHeadPosition,
      this.runtimeState.currentOpacity,
      true
    )
  }

  bindDebug() {
    if (!this.debug || this.hasBoundDebug) return

    this.debug.addBinding({
      folderTitle: 'Trail',
      targetObject: this.configuration,
      property: 'isEnabled',
      label: 'Enabled',
      onChange: (value) => {
        this.trail.object.visible = Boolean(value)
      },
    })

    this.debug.addBinding({
      folderTitle: 'Trail',
      targetObject: this.configuration.pathSettings,
      property: 'horizontalWidth',
      label: 'Width',
      options: { min: 0.2, max: 6, step: 0.01 },
    })

    this.debug.addBinding({
      folderTitle: 'Trail',
      targetObject: this.configuration.pathSettings,
      property: 'horizontalCycles',
      label: 'Curves',
      options: { min: 0.2, max: 4, step: 0.01 },
    })

    this.debug.addBinding({
      folderTitle: 'Trail',
      targetObject: this.configuration.opacitySettings,
      property: 'baseOpacity',
      label: 'Opacity',
      options: { min: 0, max: 1, step: 0.01 },
    })

    this.debug.addBinding({
      folderTitle: 'Trail',
      targetObject: this.configuration.specialEffectsSettings,
      property: 'showHeadParticles',
      label: 'Particles',
    })

    this.hasBoundDebug = true
  }

  getProgress(camera, scroll) {
    const scrollRange = (scroll?.maxCameraZ ?? 0) - (scroll?.minCameraZ ?? 0) // Scroll camera range.

    if (Number.isFinite(scrollRange) && scrollRange > 0) {
      return THREE.MathUtils.clamp(
        ((scroll?.maxCameraZ ?? camera.position.z) - camera.position.z) / scrollRange,
        0,
        1
      )
    }

    const blend = this.gallery.getPlaneBlendData(camera.position.z) // Fallback blended progress.
    if (blend) {
      const lastIndex = Math.max(this.gallery.planes.length - 1, 1) // Safe divisor.
      return THREE.MathUtils.clamp((blend.currentPlaneIndex + blend.blend) / lastIndex, 0, 1)
    }

    return this.gallery.getDepthProgress(camera.position.z) // Final fallback.
  }

  computeHeadPosition(cameraZ, progress) {
    const clampedProgress = THREE.MathUtils.clamp(progress, 0, 1) // Keep values stable.
    const { pathSettings, responsiveSettings } = this.configuration // Local shortcut.
    const horizontalCycles = Math.max(pathSettings.horizontalCycles, 0.0001) // Avoid zero cycles.
    const verticalCycles = Math.max(pathSettings.verticalCycles, 0.0001) // Avoid zero cycles.
    const isMobileViewport =
      typeof window !== 'undefined' && window.innerWidth <= responsiveSettings.mobileBreakpoint
    const responsiveStartXPosition =
      pathSettings.startXPosition + (isMobileViewport ? responsiveSettings.mobileStartXOffset : 0)
    const responsiveHorizontalWidth =
      pathSettings.horizontalWidth * (isMobileViewport ? responsiveSettings.mobileWidthScale : 1)

    const xPosition =
      responsiveStartXPosition +
      Math.sin(clampedProgress * FULL_CIRCLE_RADIANS * horizontalCycles) * responsiveHorizontalWidth

    const yPosition =
      pathSettings.startYPosition +
      Math.sin(clampedProgress * FULL_CIRCLE_RADIANS * verticalCycles) *
        pathSettings.verticalAmplitude

    const depthProgress =
      pathSettings.progressDepthOffset + clampedProgress * (1 - pathSettings.progressDepthOffset)

    const zPosition =
      cameraZ +
      pathSettings.distanceAheadOfCamera -
      (pathSettings.baseDepthOffset + depthProgress * pathSettings.depthSpan)

    this.trailHeadPosition.set(xPosition, yPosition, zPosition)
    return this.trailHeadPosition
  }

  seedInitialPoints(camera) {
    if (this.runtimeState.hasSeededInitialPoints || !camera) return

    const startPosition = this.computeHeadPosition(camera.position.z, 0).clone() // Seed anchor point.

    for (
      let index = this.configuration.pointSettings.initialSeedPointCount;
      index >= 0;
      index -= 1
    ) {
      const seedPosition = startPosition.clone() // One startup seed point.
      seedPosition.z -= index * this.configuration.pointSettings.initialSeedStepZ
      this.trail.addPoint(seedPosition)
    }

    this.runtimeState.hasSeededInitialPoints = true
  }

  getDirection(progress) {
    if (this.runtimeState.previousProgress === null) return 0

    const progressDelta = progress - this.runtimeState.previousProgress // Progress movement this frame.
    if (Math.abs(progressDelta) <= this.configuration.directionChangeEpsilon) return 0

    return Math.sign(progressDelta)
  }

  updateLength(progress, direction) {
    const { pointSettings } = this.configuration // Local shortcut.
    const lengthProgress = direction < 0 ? progress * pointSettings.reverseLengthScale : progress

    this.trail.maxPoints = Math.round(
      THREE.MathUtils.lerp(
        pointSettings.minimumPointCount,
        pointSettings.maximumPointCount,
        THREE.MathUtils.clamp(lengthProgress, 0, 1)
      )
    )

    this.trail.maxTrimPerFrame =
      direction < 0 ? pointSettings.trimPerFrameReverse : pointSettings.trimPerFrameForward
  }

  updateOpacity(progress) {
    const { opacitySettings } = this.configuration // Local shortcut.

    const startDistance = THREE.MathUtils.clamp(
      progress + opacitySettings.startVisibilityBias,
      0,
      1
    ) // Distance from start edge.

    const endDistance = 1 - progress // Distance from end edge.
    const closestEdgeDistance = Math.min(startDistance, endDistance) // Nearest edge distance.

    const edgeVisibility = THREE.MathUtils.smoothstep(
      closestEdgeDistance,
      opacitySettings.edgeFadeStart,
      opacitySettings.edgeFadeEnd
    )

    const startupVisibility =
      !this.runtimeState.hasUserMovedFromStart && progress <= opacitySettings.idleProgressThreshold
        ? opacitySettings.idleOpacityAtStart
        : 0

    const visibility = Math.max(edgeVisibility, startupVisibility) // Keep strongest visibility.
    const targetOpacity = opacitySettings.baseOpacity * visibility

    this.runtimeState.currentOpacity = THREE.MathUtils.lerp(
      this.runtimeState.currentOpacity,
      targetOpacity,
      opacitySettings.opacitySmoothing
    )

    this.trail.material.opacity = this.runtimeState.currentOpacity
  }
}

export { TrailController }
src/Experience/TrailHeadParticles.js
파일 저장

import * as THREE from 'three'

class TrailHeadParticles {
  constructor() {
    this.group = new THREE.Group()
    this.group.renderOrder = 1300

    this.isEnabled = true // Master on/off switch
    this.maxParticles = 18 // Pool size (max particles alive)
    this.spawnPerSecond = 20 // Spawn rate when active
    this.spawnRadius = 0.52 // Spawn radius around trail head
    this.speedMin = 0.05 // Minimum particle drift speed
    this.speedMax = 0.22 // Maximum particle drift speed
    this.lifeMin = 0.25 // Minimum life in seconds
    this.lifeMax = 0.6 // Maximum life in seconds
    this.sizeMin = 0.007 // Minimum particle size
    this.sizeMax = 0.02 // Maximum particle size
    this.dragPerFrame = 0.94 // Velocity damping per frame

    this.spawnAccumulator = 0 // Keeps fractional spawn remainder
    this.nextSpawnIndex = 0 // Circular index in particle pool
    this.sharedGeometry = new THREE.SphereGeometry(1, 5, 4) // Reused mesh geometry
    this.particles = [] // Particle pool

    for (let index = 0; index < this.maxParticles; index += 1) {
      const material = new THREE.MeshBasicMaterial({
        color: new THREE.Color('#f6f9ff'),
        transparent: true,
        opacity: 0,
        depthWrite: false,
        depthTest: false,
      })

      const mesh = new THREE.Mesh(this.sharedGeometry, material)
      mesh.visible = false
      this.group.add(mesh)

      this.particles.push({
        mesh,
        velocity: new THREE.Vector3(), // Current movement vector
        lifeRemaining: 0, // Remaining life in seconds
        totalLife: 0, // Initial life used for fade ratio
      })
    }
  }

  get object() {
    return this.group
  }

  setEnabled(isEnabled) {
    if (this.isEnabled && !isEnabled) {
      this.clear()
    }

    this.isEnabled = Boolean(isEnabled)
    this.group.visible = this.isEnabled
  }

  update(deltaSeconds, headPosition, opacity = 1, shouldSpawn = true) {
    const safeDelta = Math.min(Math.max(deltaSeconds || 0, 0), 0.1) // Clamp unstable frame delta

    if (this.isEnabled && shouldSpawn && safeDelta > 0) {
      this.spawnAccumulator += safeDelta * this.spawnPerSecond
      const spawnCount = Math.floor(this.spawnAccumulator)
      this.spawnAccumulator -= spawnCount

      for (let index = 0; index < spawnCount; index += 1) {
        this.spawnParticle(headPosition)
      }
    } else {
      this.spawnAccumulator = 0
    }

    const clampedOpacity = THREE.MathUtils.clamp(opacity, 0, 1)
    const drag = Math.pow(this.dragPerFrame, safeDelta * 60)

    this.particles.forEach((particle) => {
      if (particle.lifeRemaining <= 0) return

      particle.lifeRemaining -= safeDelta
      if (particle.lifeRemaining <= 0) {
        particle.lifeRemaining = 0
        particle.mesh.visible = false
        particle.mesh.material.opacity = 0
        return
      }

      particle.velocity.multiplyScalar(drag)
      particle.mesh.position.addScaledVector(particle.velocity, safeDelta)

      const lifeRatio = particle.lifeRemaining / particle.totalLife // 1 at birth, 0 at death
      particle.mesh.material.opacity = lifeRatio * clampedOpacity * 0.75
    })
  }

  spawnParticle(headPosition) {
    const particle = this.particles[this.nextSpawnIndex]
    this.nextSpawnIndex = (this.nextSpawnIndex + 1) % this.particles.length

    const angle = Math.random() * Math.PI * 2
    const radius = Math.random() * this.spawnRadius

    particle.mesh.position.set(
      headPosition.x + Math.cos(angle) * radius,
      headPosition.y + (Math.random() - 0.5) * this.spawnRadius * 0.6,
      headPosition.z + Math.sin(angle) * radius
    )

    const size = THREE.MathUtils.lerp(this.sizeMin, this.sizeMax, Math.random())
    particle.mesh.scale.setScalar(size)
    particle.mesh.visible = true

    const speed = THREE.MathUtils.lerp(this.speedMin, this.speedMax, Math.random())
    particle.velocity.set(
      (Math.random() - 0.5) * speed,
      (Math.random() - 0.5) * speed * 0.6,
      (Math.random() - 0.5) * speed
    )

    particle.totalLife = THREE.MathUtils.lerp(this.lifeMin, this.lifeMax, Math.random())
    particle.lifeRemaining = particle.totalLife
    particle.mesh.material.opacity = 0.4
  }

  dispose() {
    this.clear()
    this.particles.forEach((particle) => {
      particle.mesh.material.dispose()
    })
    this.sharedGeometry.dispose()
    this.group.clear()
    this.particles = []
  }

  clear() {
    this.spawnAccumulator = 0
    this.particles.forEach((particle) => {
      particle.lifeRemaining = 0
      particle.totalLife = 0
      particle.mesh.visible = false
      particle.mesh.material.opacity = 0
    })
  }
}

export { TrailHeadParticles }
src/Experience/index.js
파일 저장

import * as THREE from 'three'
import { Gallery } from '@/Experience/Gallery'
import { Background } from '@/Experience/Background'
import { Debug } from '@/Experience/Debug'
import { Label } from '@/Experience/Label'
import { TrailController } from '@/Experience/TrailController'

class Experience {
  constructor() {
    this.isInitialized = false
    this.isDisposed = false
    this.frameDarkPlaneCount = 2
    this.isFrameTextDark = null
    this.debug = new Debug()
    this.gallery = new Gallery(this.debug)
    this.label = new Label(this.gallery)
    this.background = new Background(this.debug)
    this.trailController = new TrailController({
      gallery: this.gallery,
      debug: this.debug,
    })
  }

  async init(scene, camera) {
    if (this.isInitialized) return

    await this.gallery.init(scene)
    this.label.init()
    this.background.init()
    this.trailController.init(scene, camera)

    const initialPlaneBlendData = this.gallery.getPlaneBlendData(camera.position.z)
    this.updateFrameTextTone(initialPlaneBlendData)

    this.isInitialized = true
  }

  updateFrameTextTone(planeBlendData) {
    if (!planeBlendData) return

    const nearestPlaneIndex =
      planeBlendData.blend >= 0.5 ? planeBlendData.nextPlaneIndex : planeBlendData.currentPlaneIndex
    const shouldUseDarkText = nearestPlaneIndex < this.frameDarkPlaneCount

    if (this.isFrameTextDark === shouldUseDarkText) return

    this.isFrameTextDark = shouldUseDarkText
    document.body.classList.toggle('frame-text-dark', shouldUseDarkText)
  }

  update(time, camera = null, scroll = null) {
    this.trailController.update(camera, scroll, time)

    // Gallery + label
    this.gallery.update(camera, scroll)
    this.label.update(camera)

    // Camera-driven updates
    if (camera) {
      // Frame text tone
      const planeBlendData = this.gallery.getPlaneBlendData(camera.position.z)
      this.updateFrameTextTone(planeBlendData)

      // Mood colors
      const moodBlendData = this.gallery.getMoodBlendData(camera.position.z)
      if (moodBlendData) {
        this.background.setMoodBlend(moodBlendData)
      }

      // Depth + velocity -> background motion response
      const depthProgress = this.gallery.getDepthProgress(camera.position.z)
      const velocityMax = scroll?.velocityMax || 1
      const velocityIntensity = THREE.MathUtils.clamp(
        Math.abs(scroll?.velocity || 0) / Math.max(velocityMax, 0.0001),
        0,
        1
      )
      const blend = planeBlendData?.blend ?? 0
      const distanceFromBlendCenter = Math.abs(blend - 0.5) * 2
      const transitionStability = THREE.MathUtils.smoothstep(distanceFromBlendCenter, 0.35, 1)
      const stabilizedVelocityIntensity = velocityIntensity * transitionStability

      this.background.setMotionResponse({
        depthProgress,
        velocityIntensity: stabilizedVelocityIntensity,
      })
    }

    // Background tick
    this.background.update(time)
  }

  dispose() {
    if (this.isDisposed) return

    this.trailController.dispose()
    this.gallery.dispose()
    this.label.dispose()
    this.background.dispose()
    this.isDisposed = true
  }
}

const world = new Experience()
export { Experience, world }
src/css/base.css
파일 저장

*,
*::after,
*::before {
  box-sizing: border-box;
}

:root {
  font-size: 12px;
  --color-text: #fff;
  --color-bg: #fff;
  --color-link: #fff;
  --color-link-hover: #fff;
  --page-padding: 1.5rem;
  --ui-font:
    'IBM Plex Mono', 'SFMono-Regular', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
    monospace;
  --ui-tracking: 0.08em;
}

body {
  margin: 0;
  color: var(--color-text);
  background-color: var(--color-bg);
  font-family: var(--ui-font);
  text-transform: uppercase;
  letter-spacing: var(--ui-tracking);
  font-size: 0.82rem;
  line-height: 1.2;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  transition: color 520ms ease;
}

body.frame-text-dark {
  --color-text: #121212;
  --color-link: #121212;
  --color-link-hover: #121212;
}

@media (scripting: enabled) {
  .loading {
    &::before,
    &::after {
      content: '';
      position: fixed;
      z-index: 10000;
    }

    &::before {
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background: var(--color-bg);
    }

    &::after {
      top: 50%;
      left: 50%;
      width: 100px;
      height: 1px;
      margin: 0 0 0 -50px;
      background: var(--color-link);
      animation: loaderAnim 1.5s ease-in-out infinite alternate forwards;
    }
  }
}

@keyframes loaderAnim {
  0% {
    transform: scaleX(0);
    transform-origin: 0% 50%;
  }

  50% {
    transform: scaleX(1);
    transform-origin: 0% 50%;
  }

  50.1% {
    transform: scaleX(1);
    transform-origin: 100% 50%;
  }

  100% {
    transform: scaleX(0);
    transform-origin: 100% 50%;
  }
}

a {
  text-decoration: none;
  color: var(--color-link);
  outline: none;
  cursor: pointer;
  transition: color 520ms ease;

  &:hover {
    text-decoration: underline;
    color: var(--color-link-hover);
  }

  &:focus {
    outline: none;
    background: lightgrey;

    &:not(:focus-visible) {
      background: transparent;
    }

    &:focus-visible {
      outline: 2px solid red;
      background: transparent;
    }
  }
}

.frame,
.frame span,
.frame p,
.frame h1 {
  transition: color 520ms ease;
}

.frame {
  padding: 3rem var(--page-padding) 0;
  display: grid;
  z-index: 1000;
  position: relative;
  grid-row-gap: 1rem;
  grid-column-gap: 2rem;
  pointer-events: none;
  justify-items: start;
  grid-template-columns: auto auto auto 1fr;
  grid-template-areas:
    'title title title title'
    'back archive github ...'
    'credit credit credit credit'
    'tags tags tags tags'
    'sponsor sponsor sponsor sponsor';

  #cdawrap {
    justify-self: start;
    grid-area: sponsor;
  }

  a,
  button {
    pointer-events: auto;
  }

  .frame__title {
    grid-area: title;
    font-size: inherit;
    font-weight: 600;
    margin: 0;
  }

  .frame__back {
    grid-area: back;
    justify-self: start;
  }

  .frame__archive {
    grid-area: archive;
    justify-self: start;
  }

  .frame__github {
    grid-area: github;
  }

  .frame__tags {
    grid-area: tags;
    display: flex;
    flex-wrap: wrap;
    gap: 1rem;
  }

  .frame__demos {
    grid-area: demos;
    display: flex;
    flex-wrap: wrap;
    gap: 1rem;
  }

  .frame__credit {
    grid-area: credit;
    margin: 0;
    opacity: 0.78;
    font-size: 0.78rem;
    font-weight: 500;
    letter-spacing: var(--ui-tracking);
    line-height: 1.2;
    text-align: right;
  }

  .frame__credit p {
    margin-bottom: 0;
  }

  @media screen and (min-width: 53em) {
    padding: var(--page-padding);
    height: 100%;
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    grid-template-columns: auto auto auto auto 1fr;
    grid-template-rows: auto auto;
    align-content: space-between;
    grid-template-areas:
      'title back github archive sponsor'
      'tags tags tags credit credit';

    .frame__tags {
      align-self: end;
    }

    .frame__credit,
    #cdawrap {
      justify-self: end;
      text-align: right;
      max-width: 300px;
    }
  }
}

.content {
  padding: var(--page-padding);
  display: flex;
  flex-direction: column;
  width: 100vw;
  position: relative;
  pointer-events: none;
  @media screen and (min-width: 53em) {
    min-height: 100vh;
    justify-content: center;
    align-items: center;
  }
}
src/css/canvas.css
파일 저장

.webgl {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  pointer-events: auto;
  touch-action: none;
  z-index: 0;
}

.debug-pane {
  position: fixed;
  right: 1rem;
  top: 4rem;
  z-index: 2000;
}

.fps-stats {
  position: fixed !important;
  left: max(1.5rem, env(safe-area-inset-left)) !important;
  top: max(3.5rem, env(safe-area-inset-top)) !important;
  right: auto !important;
  bottom: auto !important;
  z-index: 2000 !important;
  pointer-events: none;
}

@media (max-width: 53em) {
  .debug-pane {
    top: auto;
    right: max(1.2rem, env(safe-area-inset-right));
    bottom: max(0.75rem, env(safe-area-inset-bottom));
  }
}

.velocity-visualizer {
  position: fixed;
  left: max(1.52rem, env(safe-area-inset-left));
  bottom: max(4.75rem, env(safe-area-inset-bottom));
  z-index: 2000;
  width: 220px;
  padding: 0.5rem 0.625rem;
  border: 1px solid rgba(255, 255, 255, 0.2);
  background: rgba(4, 10, 18, 0.8);
  backdrop-filter: blur(6px);
  color: #ffffff;
  font-family: var(--ui-font);
  text-transform: uppercase;
  letter-spacing: var(--ui-tracking);
  pointer-events: none;
}

.velocity-visualizer__label,
.velocity-visualizer__value {
  margin: 0;
  font-size: 10px;
}

.velocity-visualizer__value {
  margin-top: 0.25rem;
}

.velocity-visualizer__track {
  position: relative;
  margin-top: 0.5rem;
  width: 100%;
  height: 6px;
  background: rgba(255, 255, 255, 0.12);
}

.velocity-visualizer__fill {
  position: absolute;
  top: 0;
  left: 50%;
  width: 0;
  height: 100%;
  background: #7fffd4;
}

.plane-label-overlay {
  position: fixed;
  inset: 0;
  z-index: 80;
  pointer-events: none;
  transition: opacity 260ms ease;
  color: rgba(244, 244, 244, 0.92);
}

.plane-label-overlay__left {
  position: absolute;
  left: clamp(2.5rem, 8vw, 12rem);
  top: 50%;
  transform: none;
  display: grid;
  gap: 0.75rem;
}

.plane-label-overlay__index {
  margin: 0;
  font-size: 9px;
  opacity: 1;
}

.plane-label-overlay__chip {
  width: 18px;
  height: 18px;
  border-radius: 50%;
  display: inline-block;
  box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.14);
}

.plane-label-overlay__right {
  position: absolute;
  right: clamp(2.5rem, 7vw, 10rem);
  top: 50%;
  transform: none;
}

.plane-label-card {
  width: min(28vw, 360px);
  display: grid;
  grid-template-columns: 1fr;
  align-items: start;
  pointer-events: none;
  opacity: 1;
  color: inherit;
  font-family: var(--ui-font);
  text-transform: uppercase;
  letter-spacing: var(--ui-tracking);
  line-height: 1.15;
}

.plane-label-card__word {
  margin: 0;
  font-size: clamp(9px, 0.78vw, 11px);
  white-space: nowrap;
}

.plane-label-card__specs {
  margin: 0;
  display: grid;
  gap: 0.25rem;
}

.plane-label-card__row {
  margin: 0;
  display: grid;
  grid-template-columns: 3.5rem 1fr;
  align-items: baseline;
  gap: 0.8rem;
}

.plane-label-card__row dt,
.plane-label-card__row dd {
  margin: 0;
}

.plane-label-card__row dt {
  font-size: 9px;
  opacity: 1;
}

.plane-label-card__value {
  font-size: clamp(9px, 0.72vw, 11px);
  opacity: 1;
}

@media (max-width: 53em) {
  .plane-label-overlay {
    inset: auto 0 max(6.25rem, env(safe-area-inset-bottom)) 0;
    display: grid;
    grid-template-columns: auto auto;
    justify-content: space-between;
    align-items: start;
    column-gap: 1.5rem;
    row-gap: 0.55rem;
    padding: 0 max(1rem, env(safe-area-inset-right)) 0 max(1rem, env(safe-area-inset-left));
  }

  .plane-label-overlay__left {
    position: static;
    transform: none;
    gap: 0.5rem;
    grid-column: 1;
    grid-row: 2;
  }

  .plane-label-overlay__right {
    position: static;
    transform: none;
    grid-column: 2;
    grid-row: 2;
  }

  .plane-label-card {
    width: auto;
    max-width: min(58vw, 270px);
  }

  .plane-label-card__word {
    font-size: 10px;
  }

  .plane-label-card__row {
    grid-template-columns: 2.8rem 1fr;
    gap: 0.6rem;
  }

  .plane-label-card__value {
    font-size: 9px;
  }
}
src/data/galleryData.js
파일 저장

import FLOWER01 from '@/assets/flower-01.webp'
import FLOWER02 from '@/assets/flower-02.webp'
import FLOWER03 from '@/assets/flower-03.webp'
import FLOWER04 from '@/assets/flower-04.webp'
import FLOWER05 from '@/assets/flower-05.webp'

const galleryPlaneData = [
  {
    fallbackColor: '#feca4f',
    accentColor: '#feca4f',
    textureSrc: FLOWER01,
    position: { x: -0.9, y: 0 },
    backgroundColor: '#fffaf0',
    blob1Color: '#ffdf94',
    blob2Color: '#fce7c4',
    label: {
      word: 'golden',
      pms: 'PMS 135 C',
      color: '#2e2e2e',
    },
  },
  {
    fallbackColor: '#80455a',
    accentColor: '#80455a',
    textureSrc: FLOWER02,
    position: { x: 0.8, y: 0 },
    backgroundColor: '#fffaf0',
    blob1Color: '#d29a41',
    blob2Color: '#bb96af',
    label: {
      word: 'violet',
      pms: 'PMS 4985 C',
      color: '#2e2e2e',
    },
  },
  {
    fallbackColor: '#fa7b71',
    accentColor: '#fa7b71',
    textureSrc: FLOWER03,
    position: { x: -0.7, y: 0 },
    backgroundColor: '#5f81ab',
    blob1Color: '#f88b8d',
    blob2Color: '#cfbbdd',
    label: {
      word: 'afterglow',
      pms: 'PMS 170 C',
      color: '#f4f4f4',
    },
  },
  {
    fallbackColor: '#3c72c6',
    accentColor: '#3c72c6',
    textureSrc: FLOWER04,
    position: { x: 1, y: 0 },
    backgroundColor: '#5b9bc2',
    blob1Color: '#ffaa00',
    blob2Color: '#00e1ff',
    label: {
      word: 'cobalt',
      pms: 'PMS 660 C',
      color: '#f4f4f4',
    },
  },
  {
    fallbackColor: '#fdd895',
    accentColor: '#fdd895',
    textureSrc: FLOWER05,
    position: { x: -0.7, y: 0 },
    backgroundColor: '#7d936e',
    blob1Color: '#fdd895',
    blob2Color: '#a5b599',
    label: {
      word: 'meadow',
      pms: 'PMS 7507 C',
      color: '#f4f4f4',
    },
  },
]

export { galleryPlaneData }
src/main.js
파일 저장

import '@/css/base.css'
import '@/css/canvas.css'

import { Engine } from '@/Experience/Engine'

const canvas = document.querySelector('.webgl')

if (!(canvas instanceof HTMLCanvasElement)) {
  throw new Error('Missing .webgl canvas element in index.html')
}

const engine = new Engine(canvas)

engine.init().catch((error) => {
  console.error('Engine initialization failed', error)
})
vite.config.js
파일 저장

import { defineConfig } from 'vite'
import { fileURLToPath, URL } from 'node:url'
import glsl from 'vite-plugin-glsl'

export default defineConfig({
  base: './',
  plugins: [glsl()],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },
})
Media credits and license evidence실행 안내·자료
파일 저장

README.md
## Credits

- Built with [Three.js](https://threejs.org/)
- Imagery from [Lummi.ai](https://lummi.ai/)
- Color chip editorial layout inspired by [@thisislandscape](https://www.instagram.com/p/DU2JTU_AEwM/?img_index=3) on Instagram


## License

[MIT](LICENSE)
LICENSE실행 안내·자료
파일 저장

MIT License

Copyright (c) 2009 - 2025 [Codrops](https://codrops.com)

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Bundled dependency licenses실행 안내·자료
파일 저장

three@0.183.0 — LICENSE
The MIT License

Copyright © 2010-2026 three.js authors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.