Codrops 원본

Building an Endless Procedural Snake with Three.js and WebGL

배경 · MIT

배경 더 보기
ORIGINAL PREVIEW
Building an Endless Procedural Snake with Three.js and WebGL 정적 미리보기

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

큰 화면으로 보기 새 탭

SOURCE FILES

원본 코드 읽기

22개 파일

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

.eslintrc.js
파일 저장

module.exports = {
  root: true,
  parser: '@typescript-eslint/parser',
  parserOptions: {
    ecmaVersion: 2020,
    sourceType: 'module',
    project: './tsconfig.json',
  },
  extends: [
    'eslint:recommended',
    'plugin:@typescript-eslint/recommended',
  ],
  plugins: ['@typescript-eslint'],
  env: {
    browser: true,
    es2020: true,
  },
  rules: {
    // Allow unused vars with underscore prefix
    '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],

    // Warn on explicit any usage
    '@typescript-eslint/no-explicit-any': 'warn',

    // Allow console.warn and console.error
    'no-console': ['warn', { allow: ['warn', 'error'] }],

    // Allow non-null assertions when necessary
    '@typescript-eslint/no-non-null-assertion': 'off',
  },
}
eslint.config.js
파일 저장

import js from "@eslint/js"
import tseslint from "typescript-eslint"
import parserTs from "@typescript-eslint/parser"

export default [
  js.configs.recommended,
  ...tseslint.configs.recommended,
  {
    ignores: ["**/node_modules/**", "**/dist/**", "*.config.js", "eslint.config.js"],
  },
  {
    files: ["**/*.{js,ts,jsx,tsx}"],
    languageOptions: { parser: parserTs },
    rules: {
      "no-unused-vars": "off",
      "@typescript-eslint/no-unused-vars": ["error"],
    },
  },
]
함께 쓰는 파일 20개 보기
src/css/style.css
파일 저장

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

:root {
  font-size: 12px;
  --color-text: #fff;
  --color-bg: #000;
  --color-link: #fff;
  --color-link-hover: #fff;
  --page-padding: 1.5rem;
}

body {
  margin: 0;
  color: var(--color-text);
  background-color: var(--color-bg);
  font-family: ui-monospace, monospace;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

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

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

  .loading::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;
}

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

a:focus {
  outline: none;
  background: lightgrey;
}

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

a:focus:focus-visible {
  outline: 2px solid #fff;
  outline-offset: 4px;
  background: transparent;
}

.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 ..."
    "tags tags tags tags"
    "sponsor sponsor sponsor sponsor";
}

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

.frame a,
.frame button {
  pointer-events: auto;
}

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

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

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

.frame .frame__github {
  grid-area: github;
}

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

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

@media screen and (min-width: 53em) {
  .frame {
    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 1fr;
    align-content: space-between;
    grid-template-areas:
      "title back github archive ..."
      "tags tags tags tags ..."
      "sponsor sponsor sponsor sponsor ...";
  }

  .frame .frame__tags {
    align-self: end;
  }

  .frame #cdawrap {
    align-self: end;
    max-width: 300px;
  }
}

.content {
  padding: var(--page-padding);
  display: flex;
  flex-direction: column;
  width: 100vw;
  position: relative;
}

@media screen and (min-width: 53em) {
  .content {
    min-height: 100vh;
    justify-content: center;
    align-items: center;
  }
}

#sizer {
  position: fixed;
  top: 0;
  left: 0;
  height: 100vh;
  width: 100vw;
  pointer-events: none;
  user-select: none;
  visibility: hidden;
  opacity: 0;
}

/* Reduced Motion Support for Accessibility */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}
src/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>WebGL Snake | Codrops</title>
  <meta
        name="description"
        content="Interactive 3D snake animation built with Three.js and WebGL, featuring procedural curve generation and adaptive quality settings" />
  <meta name="keywords" content="threejs, webgl, animation, snake, procedural, 3d, graphics" />
  <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>
  <script src="//tympanus.net/codrops/adpacks/analytics.js"></script>
</head>

<body class="demo-1">
  <noscript>
    <div style="padding: 2rem; text-align: center; color: #fff; background: #000">
      <h1>JavaScript Required</h1>
      <p>This demo requires JavaScript and WebGL support.</p>
      <p>Please enable JavaScript and use a modern browser.</p>
    </div>
  </noscript>
  <div id="sizer" aria-hidden="true"></div>
  <main>
    <header class="frame">
      <h1 class="frame__title">WebGL Snake Animation</h1>
      <a class="frame__back" href="https://tympanus.net/codrops/?p=108307">Article</a>
      <a class="frame__archive" href="https://tympanus.net/codrops/hub/">All demos</a>
      <a class="frame__github" href="https://github.com/Sujenphea/procedural-snake">GitHub</a>
      <nav class="frame__tags">
        <a href="https://tympanus.net/codrops/hub/tag/3d/">#3d</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/procedural/">#procedural</a>
      </nav>
    </header>
    <div class="content"></div>
  </main>
  <script src="https://tympanus.net/codrops/adpacks/cda_sponsor.js"></script>
  <script type="module" src="./js/main.ts"></script>
</body>

</html>
src/js/components/Snake.ts
파일 저장

import {
  BufferGeometry,
  Color,
  CubicBezierCurve3,
  DataTexture,
  DoubleSide,
  FloatType,
  Group,
  InstancedBufferAttribute,
  InstancedMesh,
  Line,
  LinearFilter,
  LineBasicMaterial,
  Matrix4,
  Mesh,
  MeshBasicMaterial,
  Object3D,
  OctahedronGeometry,
  PerspectiveCamera,
  Plane,
  Raycaster,
  RGBAFormat,
  ShaderMaterial,
  SphereGeometry,
  Texture,
  Vector3,
} from "three"
import snakeFragHigh from "../../shaders/snake/snakeFrag.glsl?raw"
import snakeVertHigh from "../../shaders/snake/snakeVert.glsl?raw"
import ballFrag from "../../shaders/ball/ballFrag.glsl?raw"
import ballVert from "../../shaders/ball/ballVert.glsl?raw"
import { createCurveGenerator } from "../curves/CurveGenerator"
import { EndlessCurve } from "../curves/EndlessCurve"
import { Input } from "../utils/input"
import { Properties } from "../utils/properties"

/* -------------------------------------------------------------------------- */
/*                                    snake                                   */
/* -------------------------------------------------------------------------- */
export type SnakeOptions = {
  length?: number
  speed?: number
  spineSegments?: number
  radialSegments?: number
  texturePoints?: number
}

class SnakeObject extends Object3D {
  private curve?: EndlessCurve
  private mesh?: InstancedMesh
  private material?: ShaderMaterial

  private positionTex?: DataTexture
  private normalTex?: DataTexture

  private distance = 0
  private spineSegments?: number
  private radialSegments?: number
  private texturePoints?: number

  // Exposed for GUI
  config = {
    length: 10,
    speed: 16,
    spineSegments: 100,
    radialSegments: 8,
  }

  uniforms = {
    u_tPosition: { value: null as Texture | null },
    u_tNormal: { value: null as Texture | null },

    // Thickness profile
    u_tailRampEnd: { value: 0.74 },
    u_scaleMin: { value: 0.13 },
    u_scaleMax: { value: 0.65 },
    u_neckStart: { value: 0.74 },
    u_neckEnd: { value: 0.95 },
    u_neckDepth: { value: 0.3 },
    u_headStart: { value: 0.85 },
    u_headEnd: { value: 1.0 },
    u_headRadius: { value: 0.75 },
    u_headBulge: { value: 0.75 },

    // Cross-section radii (defines tube surface shape)
    u_radiusN: { value: 0.5 }, // normal direction (vertical)
    u_radiusB: { value: 0.8 }, // binormal direction (horizontal / flat)

    // Effects
    u_zOffset: { value: 0.2 },
    u_twistAmount: { value: 3.0 },

    // Instance geometry shaping
    u_instanceScaleX: { value: 0.5 }, // spine direction (along curve)
    u_instanceScaleY: { value: 0.43 }, // circumferential direction
    u_instanceScaleZ: { value: 0.1 }, // outward from surface

    // Spot coloring
    u_baseColor: { value: new Color(0x2a9d8f) }, // teal
    u_spotColor: { value: new Color(0xe76f51) }, // coral
    u_spotScale: { value: 5.0 },
    u_spotThreshold: { value: 0.6 },
    u_spotSmoothness: { value: 0.1 },
    u_spotIntensity: { value: 0.8 },
    u_spotOctaves: { value: 2 },
    u_spotPersistence: { value: 0.5 },
    u_spotLacunarity: { value: 2.0 },
    u_timeOffset: { value: 0.0 },
    u_animationSpeed: { value: 0.0 },

    // Lighting
    u_cameraPosition: { value: new Vector3() },
    u_lightDirection: { value: new Vector3(0.5, 1.0, 0.3).normalize() },
    u_specularPower: { value: 27.0 },
    u_specularIntensity: { value: 0.5 },
    u_fresnelPower: { value: 3.5 },
    u_fresnelIntensity: { value: 0.3 },

    // Normal Perturbation
    u_normalPerturbScale: { value: 20.0 },
    u_normalPerturbStrength: { value: 0.05 },
    u_normalPerturbOctaves: { value: 4 },

    // Anisotropic Highlights
    u_anisotropicStrength: { value: 0.35 },
    u_anisotropicRoughness: { value: 0.5 },

    // Color Variation
    u_bellyLightness: { value: 1 },
    u_bellyWidth: { value: 0.5 },
  }

  /* ---------------------------------- utils --------------------------------- */
  private createDataTexture(): DataTexture {
    const data = new Float32Array((this.texturePoints ?? 0) * 4)
    const texture = new DataTexture(data, this.texturePoints, 1, RGBAFormat, FloatType)
    texture.minFilter = LinearFilter
    texture.magFilter = LinearFilter
    texture.needsUpdate = true
    return texture
  }

  private createGeometry(): BufferGeometry {
    const spineSegments = this.spineSegments ?? 0
    const radialSegments = this.radialSegments ?? 0

    const instanceCount = spineSegments * radialSegments
    const geometry = new OctahedronGeometry(1, 1)

    // Per-instance attributes: spineU and theta (grid layout)
    const spineUs = new Float32Array(instanceCount)
    const thetas = new Float32Array(instanceCount)

    for (let row = 0; row < spineSegments; row++) {
      const u = spineSegments > 1 ? row / (spineSegments - 1) : 0
      for (let col = 0; col < radialSegments; col++) {
        const angle = (col / radialSegments) * Math.PI * 2
        const idx = row * radialSegments + col
        spineUs[idx] = u
        thetas[idx] = angle
      }
    }

    geometry.setAttribute("spineU", new InstancedBufferAttribute(spineUs, 1))
    geometry.setAttribute("theta", new InstancedBufferAttribute(thetas, 1))

    return geometry
  }

  private updateTextures(): void {
    if (!this.curve || !this.positionTex || !this.normalTex) return

    const posData = this.positionTex?.image.data as Float32Array
    const normData = this.normalTex?.image.data as Float32Array

    const texturePoints = this.texturePoints ?? 0
    for (let i = 0; i < texturePoints; i++) {
      const u = i / (texturePoints - 1)
      const basis = this.curve.getBasisAtLocal(u)

      const idx = i * 4
      posData[idx] = basis.position.x
      posData[idx + 1] = basis.position.y
      posData[idx + 2] = basis.position.z
      posData[idx + 3] = 1.0

      // Encode normals as 0-1 range
      normData[idx] = basis.normal.x * 0.5 + 0.5
      normData[idx + 1] = basis.normal.y * 0.5 + 0.5
      normData[idx + 2] = basis.normal.z * 0.5 + 0.5
      normData[idx + 3] = 1.0
    }

    this.positionTex.needsUpdate = true
    this.normalTex.needsUpdate = true
  }

  /* ------------------------------- constructor ------------------------------ */
  buildScene(curve: EndlessCurve, options: SnakeOptions = {}) {
    this.curve = curve
    this.config.length = options.length ?? 10
    this.config.speed = options.speed ?? 2
    this.config.spineSegments = options.spineSegments ?? 100
    this.config.radialSegments = options.radialSegments ?? 8
    this.spineSegments = this.config.spineSegments
    this.radialSegments = this.config.radialSegments
    this.texturePoints = options.texturePoints ?? 100

    // Create textures for curve data
    this.positionTex = this.createDataTexture()
    this.normalTex = this.createDataTexture()

    // Create instanced geometry
    const geometry = this.createGeometry()
    const instanceCount = this.spineSegments * this.radialSegments

    // Create material with quality-based shader selection
    const vertexShader = snakeVertHigh
    const fragmentShader = snakeFragHigh

    this.uniforms.u_tPosition.value = this.positionTex
    this.uniforms.u_tNormal.value = this.normalTex
    this.material = new ShaderMaterial({
      vertexShader: vertexShader,
      fragmentShader: fragmentShader,
      uniforms: this.uniforms,
      side: DoubleSide,
    })

    // Create instanced mesh (frustumCulled=false because positions are computed in shader)
    this.mesh = new InstancedMesh(geometry, this.material, instanceCount)
    this.mesh.frustumCulled = false

    // Identity matrices — all positioning done in shader
    const matrix = new Matrix4()
    for (let i = 0; i < instanceCount; i++) {
      this.mesh.setMatrixAt(i, matrix)
    }

    this.add(this.mesh)
  }

  update(delta: number): void {
    this.distance += delta * this.config.speed
    this.curve?.configureStartEnd(this.distance, this.config.length)
    this.updateTextures()
    this.uniforms.u_timeOffset.value = this.distance * 0.1
  }

  dispose(): void {
    this.mesh?.geometry.dispose()
    this.material?.dispose()
    this.positionTex?.dispose()
    this.normalTex?.dispose()
  }

  /* --------------------------------- public --------------------------------- */
  getUniforms() {
    return this.uniforms
  }

  rebuildMesh(): void {
    this.spineSegments = this.config.spineSegments
    this.radialSegments = this.config.radialSegments
    const instanceCount = this.spineSegments * this.radialSegments

    // Dispose old mesh
    if (this.mesh) {
      this.remove(this.mesh)
      this.mesh.geometry.dispose()
    }

    // Create new geometry
    const geometry = this.createGeometry()

    // Create new instanced mesh
    this.mesh = new InstancedMesh(geometry, this.material, instanceCount)
    this.mesh.frustumCulled = false

    const matrix = new Matrix4()
    for (let i = 0; i < instanceCount; i++) {
      this.mesh.setMatrixAt(i, matrix)
    }

    this.add(this.mesh)
  }
}

/* -------------------------------------------------------------------------- */
/*                                    ball                                    */
/* -------------------------------------------------------------------------- */
class Ball {
  private config = {
    radius: 0.3,
    color: 0x44ddff,
    lerpFactor: 0.1,
  }

  // components
  sphere?: Mesh

  // uniforms
  uniforms = {
    u_color: { value: new Color(this.config.color) },
    u_lightDirection: { value: new Vector3(0.5, 1.0, 0.3).normalize() },
    u_cameraPosition: { value: new Vector3() },
    u_emissiveIntensity: { value: 0.6 },
    u_specularPower: { value: 32.0 },
    u_fresnelPower: { value: 2.5 },
  }

  /* ---------------------------------- main ---------------------------------- */
  buildScene() {
    const geometry = new SphereGeometry(this.config.radius, 32, 32)

    // Create shader material with lighting uniforms
    const material = new ShaderMaterial({
      vertexShader: ballVert,
      fragmentShader: ballFrag,
      uniforms: this.uniforms,
    })

    this.sphere = new Mesh(geometry, material)

    return this.sphere
  }
}

/* -------------------------------------------------------------------------- */
/*                                    main                                    */
/* -------------------------------------------------------------------------- */
export class Snake {
  // components
  snakeObject: SnakeObject
  endlessCurve?: EndlessCurve
  private curveOptions: import("../curves/CurveGenerator").CurveGeneratorOptions = {}
  private ball?: Ball

  // sphere
  private targetSpherePosition = new Vector3()

  // raycasting for mouse attraction
  private raycaster = new Raycaster()
  private groundPlane = new Plane(new Vector3(0, 1, 0), 0)
  private mouseTarget = new Vector3()

  // debug
  debugLine?: Line
  debugTarget?: Mesh
  debugCP1?: Mesh
  debugCP2?: Mesh
  debugHandle1?: Line
  debugHandle2?: Line

  /* ---------------------------------- utils --------------------------------- */
  private setupDebug(group: Group): void {
    // Create debug curve line
    const lineGeometry = new BufferGeometry()
    const lineMaterial = new LineBasicMaterial({ color: 0x00ff00 })
    this.debugLine = new Line(lineGeometry, lineMaterial)
    group.add(this.debugLine)

    // Create debug target sphere (mouse position on ground)
    const targetGeom = new SphereGeometry(0.5, 16, 16)
    const targetMat = new MeshBasicMaterial({ color: 0xff0000 })
    this.debugTarget = new Mesh(targetGeom, targetMat)
    group.add(this.debugTarget)

    // Create control point debug spheres
    const cpGeom = new SphereGeometry(0.3, 8, 8)
    this.debugCP1 = new Mesh(cpGeom, new MeshBasicMaterial({ color: 0x0088ff })) // blue
    this.debugCP2 = new Mesh(cpGeom, new MeshBasicMaterial({ color: 0xffff00 })) // yellow
    group.add(this.debugCP1, this.debugCP2)

    // Create handle lines (start->cp1, cp2->end)
    const handleGeom1 = new BufferGeometry()
    const handleGeom2 = new BufferGeometry()
    this.debugHandle1 = new Line(handleGeom1, new LineBasicMaterial({ color: 0x0088ff })) // blue
    this.debugHandle2 = new Line(handleGeom2, new LineBasicMaterial({ color: 0xffff00 })) // yellow
    group.add(this.debugHandle1, this.debugHandle2)

    // GUI controls
    if (!Properties.gui || !this.snakeObject) return

    const uniforms = this.snakeObject.getUniforms()
    const config = this.snakeObject.config
    const folder = Properties.gui.addFolder("Snake Shape")

    // Movement
    folder.add(config, "length", 1, 30, 1).name("Length")
    folder.add(config, "speed", 0.5, 10, 0.5).name("Speed")

    // Grid resolution (rebuild mesh on change)
    folder
      .add(config, "spineSegments", 10, 300, 10)
      .name("Spine Segments")
      .onChange(() => this.snakeObject?.rebuildMesh())
    folder
      .add(config, "radialSegments", 3, 16, 1)
      .name("Radial Segments")
      .onChange(() => this.snakeObject?.rebuildMesh())

    // Thickness profile
    folder.add(uniforms.u_tailRampEnd, "value", 0.01, 1.0, 0.01).name("Tail Ramp End")
    folder.add(uniforms.u_headStart, "value", 0.5, 1.0, 0.01).name("Head Start")
    folder.add(uniforms.u_headEnd, "value", 0.5, 1.0, 0.01).name("Head End")
    folder.add(uniforms.u_headRadius, "value", 0.0, 2.0, 0.01).name("Head Radius")
    folder.add(uniforms.u_neckStart, "value", 0.5, 0.98, 0.01).name("Neck Start")
    folder.add(uniforms.u_neckEnd, "value", 0.5, 0.98, 0.01).name("Neck End")
    folder.add(uniforms.u_neckDepth, "value", 0, 0.5, 0.05).name("Neck Depth")
    folder.add(uniforms.u_headBulge, "value", 0, 1.0, 0.05).name("Head Bulge")

    // Scale
    folder.add(uniforms.u_scaleMin, "value", 0.0, 0.5, 0.01).name("Scale Min")
    folder.add(uniforms.u_scaleMax, "value", 0.1, 3.0, 0.01).name("Scale Max")

    // Cross-section radii (tube surface shape)
    folder.add(uniforms.u_radiusN, "value", 0.05, 3.0, 0.05).name("Radius N (vert)")
    folder.add(uniforms.u_radiusB, "value", 0.05, 3.0, 0.05).name("Radius B (horiz)")

    // Effects
    folder.add(uniforms.u_zOffset, "value", -1, 1, 0.05).name("Belly Offset")
    folder.add(uniforms.u_twistAmount, "value", 0, 20, 0.5).name("Twist Amount")

    // Instance shape
    folder.add(uniforms.u_instanceScaleX, "value", 0.01, 2.0, 0.01).name("Inst Scale X (spine)")
    folder.add(uniforms.u_instanceScaleY, "value", 0.01, 2.0, 0.01).name("Inst Scale Y (circ)")
    folder.add(uniforms.u_instanceScaleZ, "value", 0.01, 2.0, 0.01).name("Inst Scale Z (out)")

    folder.close()

    // Spot coloring controls
    const spotFolder = Properties.gui.addFolder("Spot Coloring")
    spotFolder.addColor(uniforms.u_baseColor, "value").name("Base Color")
    spotFolder.addColor(uniforms.u_spotColor, "value").name("Spot Color")
    spotFolder.add(uniforms.u_spotScale, "value", 1.0, 30.0, 0.5).name("Spot Scale")
    spotFolder.add(uniforms.u_spotThreshold, "value", 0.0, 1.0, 0.05).name("Threshold")
    spotFolder.add(uniforms.u_spotSmoothness, "value", 0.01, 0.5, 0.01).name("Smoothness")
    spotFolder.add(uniforms.u_spotIntensity, "value", 0.0, 1.0, 0.05).name("Intensity")
    spotFolder.add(uniforms.u_spotOctaves, "value", 1, 4, 1).name("Octaves")
    spotFolder.add(uniforms.u_spotPersistence, "value", 0.1, 1.0, 0.05).name("Persistence")
    spotFolder.add(uniforms.u_spotLacunarity, "value", 1.5, 4.0, 0.1).name("Lacunarity")
    spotFolder.add(uniforms.u_animationSpeed, "value", 0.0, 2.0, 0.1).name("Animation Speed")
    spotFolder.open()

    // Lighting controls
    const lightingFolder = Properties.gui.addFolder("Lighting")
    lightingFolder.add(uniforms.u_specularPower, "value", 4.0, 128.0, 1.0).name("Specular Power")
    lightingFolder.add(uniforms.u_specularIntensity, "value", 0.0, 2.0, 0.1).name("Specular Intensity")
    lightingFolder.add(uniforms.u_fresnelPower, "value", 1.0, 5.0, 0.1).name("Fresnel Power")
    lightingFolder.add(uniforms.u_fresnelIntensity, "value", 0.0, 1.0, 0.05).name("Fresnel Intensity")
    // Light direction controls
    lightingFolder
      .add(uniforms.u_lightDirection.value, "x", -1, 1, 0.1)
      .name("Light X")
      .onChange(() => {
        uniforms.u_lightDirection.value.normalize()
      })
    lightingFolder
      .add(uniforms.u_lightDirection.value, "y", -1, 1, 0.1)
      .name("Light Y")
      .onChange(() => {
        uniforms.u_lightDirection.value.normalize()
      })
    lightingFolder
      .add(uniforms.u_lightDirection.value, "z", -1, 1, 0.1)
      .name("Light Z")
      .onChange(() => {
        uniforms.u_lightDirection.value.normalize()
      })
    lightingFolder.open()

    // Normal Perturbation controls (bumpy scale texture)
    const normalFolder = Properties.gui.addFolder("Normal Perturbation")
    normalFolder.add(uniforms.u_normalPerturbScale, "value", 5.0, 50.0, 1.0).name("Bump Scale")
    normalFolder.add(uniforms.u_normalPerturbStrength, "value", 0.0, 1.0, 0.05).name("Bump Strength")
    normalFolder.add(uniforms.u_normalPerturbOctaves, "value", 1, 6, 1).name("Bump Octaves")
    normalFolder.open()

    // Anisotropic Highlight controls (elongated reflections)
    const anisotropicFolder = Properties.gui.addFolder("Anisotropic Highlights")
    anisotropicFolder.add(uniforms.u_anisotropicStrength, "value", 0.0, 1.0, 0.05).name("Anisotropic Strength")
    anisotropicFolder.add(uniforms.u_anisotropicRoughness, "value", 0.1, 1.0, 0.05).name("Anisotropic Roughness")
    anisotropicFolder.open()

    // Color Variation controls (belly lighter than back)
    const colorVarFolder = Properties.gui.addFolder("Color Variation")
    colorVarFolder.add(uniforms.u_bellyLightness, "value", 0.0, 2.0, 0.1).name("Belly Lightness")
    colorVarFolder.add(uniforms.u_bellyWidth, "value", 0.1, 1.0, 0.05).name("Belly Width")
    colorVarFolder.open()

    // Curve behavior controls (boids-style)
    const curveFolder = Properties.gui.addFolder("Curve Behavior")
    const opts = this.curveOptions as Required<typeof this.curveOptions>

    // Turn rate
    curveFolder.add(opts, "maxTurnRate", 0.1, Math.PI / 2, 0.05).name("Max Turn Rate")

    // Orbit
    curveFolder.add(opts, "orbitRadius", 0.5, 10, 0.1).name("Orbit Radius")

    // Force weights
    curveFolder.add(opts, "orbitWeight", 0, 2, 0.1).name("Orbit Weight")
    curveFolder.add(opts, "wanderWeight", 0, 1, 0.05).name("Wander Weight")

    // Wander
    curveFolder.add(opts, "wanderStrength", 0, Math.PI / 4, 0.05).name("Wander Strength")
    curveFolder.add(opts, "tiltStrength", 0, Math.PI / 8, 0.05).name("Tilt Strength")

    // Coil
    curveFolder.add(opts, "coilAmplitude", 0, 10, 0.5).name("Coil Amplitude")
    curveFolder.add(opts, "coilFrequency", 0.1, 2, 0.05).name("Coil Frequency")
    curveFolder.close()
  }

  /* ---------------------------------- main ---------------------------------- */
  constructor() {
    this.snakeObject = new SnakeObject()
  }

  buildScene() {
    const group = new Group()

    // Get quality-based configuration
    const config = Properties.getSnakeConfig()

    // Calculate orbit radius based on viewport width for responsive behavior
    // Small screens (mobile): 1.2, Large screens (desktop): 2.5
    const orbitRadius = Math.max(1.0, Math.min(2.5, Properties.viewportWidth / 800))

    // Create endless curve with boids-style steering
    this.curveOptions = {
      segmentLength: { min: 4, max: 8 },
      // Turn rate limit (only smoothing mechanism)
      maxTurnRate: 1.15, // 30° per segment

      // Orbit behavior (viewport-responsive)
      orbitRadius: orbitRadius,

      // Force weights
      orbitWeight: 1.0,
      wanderWeight: 0.2,

      // Wander
      wanderStrength: Math.PI / 12, // 15°
      tiltStrength: Math.PI / 24, // 7.5°

      // Coil
      coilAmplitude: 3.0,
      coilFrequency: 0.25,
    }
    const curveGenerator = createCurveGenerator(this.curveOptions)
    const endlessCurve = new EndlessCurve(curveGenerator)
    this.endlessCurve = endlessCurve

    // Apply quality-based scale values
    this.snakeObject.uniforms.u_scaleMin.value = config.scaleMin
    this.snakeObject.uniforms.u_scaleMax.value = config.scaleMax

    // Create snake with quality-appropriate settings
    this.snakeObject.buildScene(endlessCurve, {
      length: config.length,
      speed: 4,
      spineSegments: config.spineSegments,
      radialSegments: config.radialSegments,
      texturePoints: config.texturePoints,
    })
    group.add(this.snakeObject)

    // Create target sphere
    this.ball = new Ball()
    group.add(this.ball.buildScene())

    // Setup debug visuals and GUI only when enabled
    if (config.enableDebug) {
      this.setupDebug(group)
    }

    return group
  }

  resize() {}

  update(camera: PerspectiveCamera, delta: number): void {
    // raycast
    this.raycaster.setFromCamera(Input.mouseXY, camera)
    this.raycaster.ray.intersectPlane(this.groundPlane, this.mouseTarget)

    // lerp mouse
    this.targetSpherePosition.lerp(this.mouseTarget, 0.1)

    // Update target sphere visual position and shader uniforms
    if (this.ball) {
      this.ball.sphere?.position.copy(this.targetSpherePosition)
      this.ball.uniforms.u_cameraPosition.value.copy(camera.position)
    }

    // update curve
    this.endlessCurve?.setTarget(this.targetSpherePosition)

    // update snake
    this.snakeObject?.update(delta)
    this.snakeObject.uniforms.u_cameraPosition.value.copy(camera.position)

    // Update debug visualizations only in development
    if (import.meta.env.DEV) {
      // Update debug target position
      this.debugTarget?.position.copy(this.mouseTarget)

      // Update debug curve line
      if (this.debugLine && this.endlessCurve) {
        const points: Vector3[] = []
        for (let i = 0; i <= 50; i++) {
          const u = i / 50
          points.push(this.endlessCurve.getPointAtLocal(u))
        }
        this.debugLine.geometry.setFromPoints(points)

        // Update control point debug spheres (show last curve segment)
        if (this.debugCP1 && this.debugCP2) {
          const curves = this.endlessCurve.curves
          if (curves.length > 0) {
            const lastCurve = curves[curves.length - 1] as CubicBezierCurve3
            this.debugCP1.position.copy(lastCurve.v1) // cp1
            this.debugCP2.position.copy(lastCurve.v2) // cp2

            // Update handle lines
            if (this.debugHandle1 && this.debugHandle2) {
              this.debugHandle1.geometry.setFromPoints([lastCurve.v0, lastCurve.v1])
              this.debugHandle2.geometry.setFromPoints([lastCurve.v2, lastCurve.v3])
            }
          }
        }
      }
    }
  }
}
src/js/curves/CurveGenerator.ts
파일 저장

import { createNoise2D } from "simplex-noise"
import { CubicBezierCurve3, Vector3 } from "three"

/* -------------------------------------------------------------------------- */
/*                              Steering Forces                               */
/* -------------------------------------------------------------------------- */
/**
 * Wander force: returns noise-based direction for organic movement
 */
function wanderForce(
  currentDir: Vector3,
  noise2D: (x: number, y: number) => number,
  noiseTime: number,
  wanderStrength: number,
  tiltStrength: number
): Vector3 {
  const result = currentDir.clone()

  // Horizontal wander
  const wanderNoise = noise2D(noiseTime, 0)
  const wanderAngle = wanderNoise * wanderStrength
  const up = new Vector3(0, 1, 0)
  result.applyAxisAngle(up, wanderAngle)

  // Vertical tilt
  const tiltNoise = noise2D(noiseTime, 100)
  const tiltAngle = tiltNoise * tiltStrength
  const side = new Vector3().crossVectors(up, result)
  if (side.lengthSq() > 0.01) {
    side.normalize()
    result.applyAxisAngle(side, tiltAngle)
  }

  return result.normalize()
}

/**
 * Limit turn rate: rotate toward desired direction, capped at maxRate
 * Uses axis-angle rotation instead of lerp (lerp fails for large angles)
 */
function limitTurnRate(current: Vector3, desired: Vector3, maxRate: number): Vector3 {
  const angle = current.angleTo(desired)
  if (angle <= maxRate) return desired.clone()
  if (angle < 0.001) return current.clone()

  // Calculate rotation axis (perpendicular to both vectors)
  const axis = new Vector3().crossVectors(current, desired)

  // Handle parallel/anti-parallel case
  if (axis.lengthSq() < 0.0001) {
    // Find any perpendicular axis
    axis.set(0, 1, 0)
    if (Math.abs(current.y) > 0.9) axis.set(1, 0, 0)
    axis.crossVectors(current, axis).normalize()
  } else {
    axis.normalize()
  }

  // Rotate current toward desired by exactly maxRate
  return current.clone().applyAxisAngle(axis, maxRate)
}

/* -------------------------------------------------------------------------- */
/*                              Options & Export                              */
/* -------------------------------------------------------------------------- */

export type CurveGeneratorOptions = {
  segmentLength?: { min: number; max: number }

  // Turn rate limit (radians per segment)
  maxTurnRate?: number

  // Orbit behavior
  orbitRadius?: number

  // Force weights
  orbitWeight?: number
  wanderWeight?: number

  // Wander parameters
  wanderStrength?: number
  tiltStrength?: number

  // Coil parameters
  coilAmplitude?: number // vertical extent of coil
  coilFrequency?: number // oscillations per orbit revolution
}

/**
 * Creates a boids-style curve generator with clean force-based steering.
 * No momentum accumulation - just forces + turn rate limiting.
 */
export function createCurveGenerator(options: CurveGeneratorOptions = {}): (target?: Vector3) => CubicBezierCurve3 {
  // Set defaults
  options.segmentLength ??= { min: 4, max: 8 }
  options.maxTurnRate ??= Math.PI / 6 // 30° per segment
  options.orbitRadius ??= 8
  options.orbitWeight ??= 1.0
  options.wanderWeight ??= 0.15 // gentler blend
  options.wanderStrength ??= Math.PI / 24 // 7.5° - much gentler
  options.tiltStrength ??= Math.PI / 48 // 3.75° - subtle vertical movement
  options.coilAmplitude ??= 3.0
  options.coilFrequency ??= 0.25 // 4 orbit revolutions per full up-down cycle

  // noise for wander
  const noise2D = createNoise2D()

  // state
  let lastPoint = new Vector3(0, 0, 0)
  let lastDir = new Vector3(1, 0, 0)

  let noiseTime = 0
  let orbitAngle = 0 // accumulated orbit phase (radians)
  let coilActivation = 0 // 0..1 smooth ramp

  /* ---------------------------------- main ---------------------------------- */
  return function nextCurve(target?: Vector3): CubicBezierCurve3 {
    // read options (allows GUI updates)
    const segmentLength = options.segmentLength!
    const maxTurnRate = options.maxTurnRate!
    const orbitRadius = options.orbitRadius!
    const orbitWeight = options.orbitWeight!
    const wanderWeight = options.wanderWeight!
    const wanderStrength = options.wanderStrength!
    const tiltStrength = options.tiltStrength!
    const coilAmplitude = options.coilAmplitude!
    const coilFrequency = options.coilFrequency!

    // segment length
    const length = segmentLength.min + Math.random() * (segmentLength.max - segmentLength.min)

    // update time
    noiseTime += 0.01

    /* ----------------------------- steering force ----------------------------- */
    let desiredDir = new Vector3()

    // seek force
    if (target) {
      // get target
      const toTarget = target.clone().sub(lastPoint)
      const dist = toTarget.length()
      const targetDir = toTarget.normalize()
      const tangent = new Vector3(-targetDir.z, 0, targetDir.x)

      // update coil
      const isOrbiting = dist < orbitRadius * 1.5
      if (isOrbiting) {
        const circumference = 2 * Math.PI * orbitRadius
        const arcFraction = length / circumference
        orbitAngle += arcFraction * 2 * Math.PI
        coilActivation = Math.min(1, coilActivation + 0.15)
      } else {
        coilActivation = Math.max(0, coilActivation - 0.15)
      }

      // update direction
      if (dist > orbitRadius * 1.5) {
        // seek directly toward target
        desiredDir = targetDir
      } else {
        // orbit with radius correction
        const radiusError = dist - orbitRadius
        const radialStrength = radiusError * 0.1

        // coil - vertical oscillation (derivative of sin = cos)
        const coilY = coilAmplitude * coilFrequency * Math.cos(coilFrequency * orbitAngle) * coilActivation
        const coilTangent = new Vector3(tangent.x, coilY, tangent.z)

        desiredDir = coilTangent.clone().addScaledVector(targetDir, radialStrength).normalize()
      }
    } else {
      // update direction
      desiredDir.add(lastDir.clone().multiplyScalar(orbitWeight))
    }

    // wander force (additive blend for organic movement)
    const wander = wanderForce(lastDir, noise2D, noiseTime, wanderStrength, tiltStrength)
    const wanderDelta = wander.clone().sub(lastDir)
    desiredDir.add(wanderDelta.multiplyScalar(wanderWeight))

    // normalise
    if (desiredDir.lengthSq() > 0.001) {
      desiredDir.normalize()
    } else {
      desiredDir = lastDir.clone()
    }

    /* --------------------------------- bounds --------------------------------- */
    // update angle limit
    const newDir = limitTurnRate(lastDir, desiredDir, maxTurnRate)

    /* -------------------------------- generate -------------------------------- */
    // calculate endpoint
    const endPoint = lastPoint.clone().add(newDir.clone().multiplyScalar(length))

    // control distance - longer handles for sharper turns = smoother curves
    const turnAngle = lastDir.angleTo(newDir)
    const turnFactor = Math.min(1, turnAngle / (Math.PI / 2)) // 0 for straight, 1 for 90°
    const controlDist = length * (0.33 + 0.34 * turnFactor) // 0.33-0.67 of length

    const cp1 = lastPoint.clone().add(lastDir.clone().multiplyScalar(controlDist))
    const cp2 = endPoint.clone().sub(newDir.clone().multiplyScalar(controlDist))

    // create curve
    const curve = new CubicBezierCurve3(lastPoint.clone(), cp1, cp2, endPoint.clone())

    /* ------------------------------- post update ------------------------------ */
    // update state
    lastPoint = endPoint.clone()
    lastDir = newDir.clone()

    return curve
  }
}
src/js/curves/EndlessCurve.ts
파일 저장

import { CubicBezierCurve3, CurvePath, Vector3 } from "three"

/* -------------------------------------------------------------------------- */
/*                                    types                                   */
/* -------------------------------------------------------------------------- */
export type CurveBasis = {
  position: Vector3
  normal: Vector3
  tangent: Vector3
}

/* -------------------------------------------------------------------------- */
/*                                    utils                                   */
/* -------------------------------------------------------------------------- */
/**
 * Get analytical tangent for cubic bezier curve.
 * Three.js uses numerical differentiation which introduces errors at boundaries.
 * For t=0 and t=1, use exact derivative formula instead.
 */
function getAnalyticalTangent(curve: CubicBezierCurve3, t: number): Vector3 {
  if (t === 0) {
    // derivative at t=0 is proportional to (CP1 - P0)
    return curve.v1.clone().sub(curve.v0).normalize()
  } else if (t === 1) {
    // derivative at t=1 is proportional to (P3 - CP2)
    return curve.v3.clone().sub(curve.v2).normalize()
  } else {
    // for interior points, Three.js numerical differentiation is fine
    return curve.getTangentAt(t).normalize()
  }
}

/* -------------------------------------------------------------------------- */
/*                                    main                                    */
/* -------------------------------------------------------------------------- */
/**
 * An endless curve that generates new segments on demand and removes old ones.
 * Uses parallel transport (rotation-minimizing frame) for smooth normal computation.
 */
export class EndlessCurve extends CurvePath<Vector3> {
  private distanceOffset = 0
  private uStart = 0
  private uLength = 1
  private nextCurveFn: (target?: Vector3) => CubicBezierCurve3
  private target?: Vector3

  // parallel transport frame cache
  private frameCache: { normals: Vector3[]; uValues: number[] } = { normals: [], uValues: [] }
  private samplesPerCurve = 10
  private lastNormal = new Vector3(0, 1, 0)

  /* -------------------------------- public api ------------------------------ */
  setTarget(target: Vector3): void {
    this.target = target
  }

  /* --------------------------------- helpers -------------------------------- */
  private localDistance(globalDistance: number): number {
    return globalDistance - this.distanceOffset
  }

  private getLengthSafe(): number {
    if (!this.curves.length) return 0
    return this.getLength()
  }

  /* ---------------------------- curve management ---------------------------- */
  /**
   * Add a curve and compute parallel transport frames for it.
   */
  addCurve(curve: CubicBezierCurve3): void {
    const curveIndex = this.curves.length
    this.curves.push(curve)

    // invalidate length cache
    ;(this as unknown as { cacheLengths: number[] | null }).cacheLengths = null

    // compute frames for new curve segment
    this.computeFramesForCurve(curveIndex)
  }

  /* ------------------------ frame computation (PTF) ------------------------- */
  // ptf = Parallel Transport Frame (rotation-minimizing)

  /**
   * Compute parallel transport frames for a curve segment.
   */
  private computeFramesForCurve(curveIndex: number): void {
    const curve = this.curves[curveIndex] as CubicBezierCurve3

    // get the previous normal and tangent
    let prevNormal = this.lastNormal.clone()
    let prevTangent: Vector3

    if (curveIndex > 0) {
      const prevCurve = this.curves[curveIndex - 1] as CubicBezierCurve3
      // use analytical tangent at boundary for exact match
      prevTangent = getAnalyticalTangent(prevCurve, 1)
    } else {
      // use analytical tangent at boundary for exact match
      prevTangent = getAnalyticalTangent(curve, 0)
      // ensure initial normal is perpendicular to initial tangent
      prevNormal = this.getArbitraryPerpendicular(prevTangent)
    }

    // sample frames along this curve
    for (let i = 0; i <= this.samplesPerCurve; i++) {
      const localU = i / this.samplesPerCurve
      // use analytical tangent at boundaries (0 and 1) for exact continuity
      const tangent = getAnalyticalTangent(curve, localU)

      // parallel transport: rotate previous normal to be perpendicular to new tangent
      const normal = this.parallelTransport(prevNormal, prevTangent, tangent)

      this.frameCache.normals.push(normal.clone())
      // u values will be recalculated when needed
      this.frameCache.uValues.push(0)

      prevNormal = normal
      prevTangent = tangent
    }

    // store last normal for next curve
    this.lastNormal = prevNormal.clone()

    // recalculate all u values
    this.recalculateUValues()
  }

  /**
   * Parallel transport algorithm: rotate the normal to stay perpendicular to the new tangent
   * while minimizing rotation.
   */
  private parallelTransport(prevNormal: Vector3, prevTangent: Vector3, newTangent: Vector3): Vector3 {
    const dot = prevTangent.dot(newTangent)

    // if tangents are nearly parallel, just project the normal
    if (dot > 0.9999) {
      return prevNormal.clone()
    }

    // compute rotation axis (perpendicular to both tangents)
    const axis = new Vector3().crossVectors(prevTangent, newTangent)

    if (axis.lengthSq() < 0.0001) {
      // tangents are anti-parallel (180 degree turn) - use any perpendicular axis
      axis.set(1, 0, 0)
      if (Math.abs(prevTangent.dot(axis)) > 0.9) {
        axis.set(0, 1, 0)
      }
      axis.crossVectors(axis, prevTangent).normalize()
    } else {
      axis.normalize()
    }

    // compute rotation angle
    const angle = Math.acos(Math.max(-1, Math.min(1, dot)))

    // rotate the normal
    const rotatedNormal = prevNormal.clone()
    rotatedNormal.applyAxisAngle(axis, angle)

    // ensure orthogonality (project onto plane perpendicular to new tangent)
    rotatedNormal.sub(newTangent.clone().multiplyScalar(rotatedNormal.dot(newTangent)))
    rotatedNormal.normalize()

    return rotatedNormal
  }

  /**
   * Get an arbitrary vector perpendicular to the given vector.
   */
  private getArbitraryPerpendicular(v: Vector3): Vector3 {
    const up = new Vector3(0, 1, 0)
    if (Math.abs(v.dot(up)) > 0.9) {
      up.set(1, 0, 0)
    }
    return new Vector3().crossVectors(v, up).normalize()
  }

  /**
   * Recalculate u values for all cached frames based on current curve lengths.
   */
  private recalculateUValues(): void {
    if (this.curves.length === 0) return

    const totalLength = this.getLength()
    const curveLengths = this.getCurveLengths()

    let frameIndex = 0
    for (let curveIndex = 0; curveIndex < this.curves.length; curveIndex++) {
      const startLength = curveIndex > 0 ? curveLengths[curveIndex - 1] : 0
      const endLength = curveLengths[curveIndex]
      const curveLength = endLength - startLength

      for (let i = 0; i <= this.samplesPerCurve; i++) {
        if (frameIndex >= this.frameCache.uValues.length) break
        const localU = i / this.samplesPerCurve
        this.frameCache.uValues[frameIndex] = (startLength + curveLength * localU) / totalLength
        frameIndex++
      }
    }
  }

  /* ------------------------- frame interpolation ---------------------------- */
  /**
   * Interpolate normal from the frame cache at parameter u.
   */
  private interpolateNormal(u: number): Vector3 {
    const cache = this.frameCache

    if (cache.normals.length === 0) {
      // fallback: compute arbitrary perpendicular
      const tangent = this.getTangentAt(u).normalize()
      return this.getArbitraryPerpendicular(tangent)
    }

    if (cache.normals.length === 1) {
      return cache.normals[0].clone()
    }

    // binary search for surrounding samples
    let low = 0
    let high = cache.uValues.length - 1

    // handle edge cases
    if (u <= cache.uValues[0]) {
      return cache.normals[0].clone()
    }
    if (u >= cache.uValues[high]) {
      return cache.normals[high].clone()
    }

    while (low < high - 1) {
      const mid = Math.floor((low + high) / 2)
      if (cache.uValues[mid] <= u) {
        low = mid
      } else {
        high = mid
      }
    }

    // linear interpolation factor
    const uLow = cache.uValues[low]
    const uHigh = cache.uValues[high]
    const t = uHigh > uLow ? (u - uLow) / (uHigh - uLow) : 0

    // lerp between normals and normalize
    const normal = cache.normals[low].clone().lerp(cache.normals[high], t).normalize()

    return normal
  }

  /* -------------------------- basis computation ----------------------------- */
  /**
   * Get the position, normal, and tangent at parameter u along the curve.
   */
  getBasisAt(u: number): CurveBasis {
    const position = this.getPointAt(u)
    const tangent = this.getTangentAt(u).normalize()
    const normal = this.interpolateNormal(u)

    return { position, normal, tangent }
  }

  /* ------------------------ curve stream management ------------------------- */
  // dynamic curve generation and removal

  fillLength(length: number): void {
    const localLen = this.localDistance(length)
    const currentLen = this.getLengthSafe()

    if (localLen < currentLen) return

    const newCurve = this.nextCurveFn(this.target)
    this.addCurve(newCurve)
    this.fillLength(length)
  }

  removeCurvesBefore(position: number): void {
    const p = this.localDistance(position)
    const lengths = this.getCurveLengths()

    let remove = 0
    let distanceOffset = 0

    for (let i = 0; i < lengths.length; i++) {
      if (p < lengths[i]) break
      distanceOffset = lengths[i]
      remove++
    }

    if (remove) {
      this.distanceOffset += distanceOffset
      this.curves = this.curves.slice(remove)

      // remove corresponding frames from cache
      const framesToRemove = remove * (this.samplesPerCurve + 1)
      this.frameCache.normals = this.frameCache.normals.slice(framesToRemove)
      this.frameCache.uValues = this.frameCache.uValues.slice(framesToRemove)

      // reset internal cache
      ;(this as unknown as { cacheLengths: number[] | null }).cacheLengths = null

      // recalculate u values for remaining frames
      this.recalculateUValues()
    }
  }

  configureStartEnd(position: number, length: number): void {
    this.fillLength(position + length)
    this.removeCurvesBefore(position)

    const localPos = this.localDistance(position)
    const totalLen = this.getLengthSafe()

    this.uStart = totalLen > 0 ? localPos / totalLen : 0
    this.uLength = totalLen > 0 ? length / totalLen : 1
  }

  /* ----------------------- local coordinate system -------------------------- */
  getPointAtLocal(u: number): Vector3 {
    const u2 = this.uStart + this.uLength * u
    return this.getPointAt(Math.min(u2, 1))
  }

  getBasisAtLocal(u: number): CurveBasis {
    const u2 = this.uStart + this.uLength * u
    return this.getBasisAt(Math.min(u2, 1))
  }

  /* ---------------------------------- main ---------------------------------- */
  constructor(nextCurveFn: (target?: Vector3) => CubicBezierCurve3) {
    super()
    this.nextCurveFn = nextCurveFn
  }
}
src/js/main.ts
파일 저장

import { NoToneMapping, PerspectiveCamera, Scene, SRGBColorSpace, WebGLRenderer } from "three"
import "../css/style.css"
import { Snake } from "./components/Snake"
import { RAFCollection } from "./utils/RAFCollection"
import { Input } from "./utils/input"
import { Properties } from "./utils/properties"

class App {
  gl: WebGLRenderer
  scene: Scene
  camera: PerspectiveCamera

  // components
  snake: Snake

  // variables
  dateTime = performance.now()
  size = { width: 0, height: 0 }
  isContextLost = false

  // Pre-bound methods to avoid creating new functions
  private boundUpdate = this.update.bind(this)
  private boundResize = this.resize.bind(this)

  /* ---------------------------------- main ---------------------------------- */
  constructor() {
    // init viewport
    Properties.viewportWidth = window.innerWidth
    Properties.viewportHeight = window.innerHeight

    // get config
    const config = Properties.getSnakeConfig()

    // setup gl
    this.gl = new WebGLRenderer({
      alpha: false,
      antialias: config.shaderQuality === "high",
      powerPreference: "high-performance",
      premultipliedAlpha: false,
    })
    this.gl.outputColorSpace = SRGBColorSpace
    this.gl.toneMapping = NoToneMapping
    this.gl.setSize(window.innerWidth, window.innerHeight)

    this.gl.domElement.id = "canvas"
    this.gl.domElement.setAttribute("aria-hidden", "true")
    this.gl.domElement.style.position = "fixed"
    this.gl.domElement.style.left = "0px"
    this.gl.domElement.style.top = "0px"
    this.gl.domElement.style.pointerEvents = "none"
    this.gl.setPixelRatio(config.dpr)
    document.body.prepend(this.gl.domElement)

    // Handle WebGL context loss (common on mobile when backgrounding app)
    this.gl.domElement.addEventListener("webglcontextlost", (event) => {
      event.preventDefault()
      console.warn("WebGL context lost")
      this.isContextLost = true
    })

    this.gl.domElement.addEventListener("webglcontextrestored", () => {
      console.log("WebGL context restored")
      this.isContextLost = false
      // Rebuild scene after context restoration
      this.buildScene()
    })

    // setup scene
    this.scene = new Scene()
    this.camera = new PerspectiveCamera(45, Properties.viewportWidth / Properties.viewportHeight, 0.1, 200)
    this.camera.position.set(0, 15, 20)
    this.camera.lookAt(0, 0, 0)

    // pre init
    Input.preInit()

    // init components
    this.snake = new Snake()
    this.buildScene()

    // Add resize listener
    this.resize()
    window.addEventListener("resize", this.boundResize)
    this.update()
  }

  load() {}

  resize() {
    const sizerEl = document.getElementById("sizer")

    // fallback to window dimensions
    const newWidth = sizerEl ? sizerEl.getBoundingClientRect().width : window.innerWidth
    const newHeight = sizerEl ? sizerEl.getBoundingClientRect().height : window.innerHeight

    // update
    if (this.size.width !== newWidth || this.size.height !== newHeight) {
      Properties.viewportWidth = newWidth
      Properties.viewportHeight = newHeight

      Input.updateViewportCache()

      this.size.width = newWidth
      this.size.height = newHeight

      this.gl.setSize(newWidth, newHeight)

      this.camera.aspect = newWidth / newHeight
      this.camera.updateProjectionMatrix()
    }
  }

  buildScene() {
    this.scene.add(this.snake.buildScene())
  }

  update() {
    window.requestAnimationFrame(this.boundUpdate)

    // Skip rendering if WebGL context is lost
    if (this.isContextLost) {
      return
    }

    // get time
    const currDateTime = performance.now()
    let delta = (currDateTime - this.dateTime) / 1e3
    this.dateTime = currDateTime
    delta = Math.min(delta, 1 / 20)

    // update
    RAFCollection.forEach((callback) => callback(delta))

    // update components
    this.snake.update(this.camera, delta)

    // render
    this.gl.render(this.scene, this.camera)

    // post update
    Input.postUpdate()
  }

  // Clean up resources on page unload
  destroy() {
    // Remove resize listener
    window.removeEventListener("resize", this.boundResize)

    // Cleanup input event listeners
    Input.destroy()

    // Dispose Three.js resources
    this.gl.dispose()
  }
}

// Store app instance for cleanup
let app: App | null = null

window.addEventListener("load", () => {
    app = new App()
})

// Clean up on page unload
window.addEventListener("beforeunload", () => {
  if (app) {
    app.destroy()
  }
})
src/js/utils/Emitter.ts
파일 저장

export class Emitter<T = void> {
  private listeners = new Set<(data: T) => void>()
  private onceListeners = new Set<(data: T) => void>()

  add(callback: (data: T) => void): void {
    this.listeners.add(callback)
  }

  addOnce(callback: (data: T) => void): void {
    this.onceListeners.add(callback)
  }

  remove(callback: (data: T) => void): void {
    this.listeners.delete(callback)
    this.onceListeners.delete(callback)
  }

  dispatch(data: T): void {
    this.listeners.forEach((cb) => cb(data))
    this.onceListeners.forEach((cb) => cb(data))
    this.onceListeners.clear()
  }

  clear(): void {
    this.listeners.clear()
    this.onceListeners.clear()
  }
}
src/js/utils/RAFCollection.ts
파일 저장

type RAFCallback = (delta: number) => void

export class RAFCollection {
  // Use Set for O(1) add/remove operations instead of array
  private static callbacks = new Set<RAFCallback>()

  /**
   * Add a callback to the RAF loop.
   * Prevents duplicate registration of the same callback.
   * @param callback - Function to call each frame with delta time
   * @returns true if callback was added, false if already registered
   */
  static add(callback: RAFCallback): boolean {
    if (this.callbacks.has(callback)) {
      return false
    }
    this.callbacks.add(callback)
    return true
  }

  /**
   * Remove a callback from the RAF loop.
   * @param callback - Function to remove
   * @returns true if callback was removed, false if not found
   */
  static remove(callback: RAFCallback): boolean {
    return this.callbacks.delete(callback)
  }

  /**
   * Check if a callback is already registered.
   * @param callback - Function to check
   */
  static has(callback: RAFCallback): boolean {
    return this.callbacks.has(callback)
  }

  /**
   * Remove all callbacks from the RAF loop.
   */
  static clear(): void {
    this.callbacks.clear()
  }

  /**
   * Get the number of registered callbacks.
   */
  static get length(): number {
    return this.callbacks.size
  }

  /**
   * Iterate over all callbacks with delta time.
   * @param fn - Function to call for each callback
   */
  static forEach(fn: (callback: RAFCallback) => void): void {
    this.callbacks.forEach(fn)
  }

  // Legacy support: keep rafArray for backwards compatibility
  static get rafArray() {
    return Array.from(this.callbacks).map((callback) => ({ callback }))
  }
}
src/js/utils/input.ts
파일 저장

import { Vector2 } from "three"
import { Properties } from "./properties"

export class Input {
  static mouseXY = new Vector2()
  static mousePixelXY = new Vector2()
  static mouseScreenXY = new Vector2()

  static deltaXY = new Vector2()
  static deltaScreenXY = new Vector2()
  static deltaPixelXY = new Vector2()

  static _prevMouseXY = new Vector2()
  static prevMouseXY = new Vector2()
  static _prevMouseScreenXY = new Vector2()
  static prevMouseScreenXY = new Vector2()
  static _prevMousePixelXY = new Vector2()
  static prevMousePixelXY = new Vector2()

  // cache viewport dimensions for input calculations
  private static cachedViewportWidth = 0
  private static cachedViewportHeight = 0
  private static invViewportWidth = 0
  private static invViewportHeight = 0

  // store bound functions to fix memory leak
  private static boundOnMove: (e: MouseEvent) => void
  private static boundTouchMove: (e: TouchEvent) => void

  /* --------------------------------- public --------------------------------- */
  static updateViewportCache() {
    if (
      this.cachedViewportWidth !== Properties.viewportWidth ||
      this.cachedViewportHeight !== Properties.viewportHeight
    ) {
      this.cachedViewportWidth = Properties.viewportWidth
      this.cachedViewportHeight = Properties.viewportHeight
      this.invViewportWidth = 1 / Properties.viewportWidth
      this.invViewportHeight = 1 / Properties.viewportHeight
    }
  }

  static preInit() {
    this.updateViewportCache()

    this.boundOnMove = this._onMove.bind(this) as (e: MouseEvent) => void
    this.boundTouchMove = this._getTouch(this, this._onMove)

    document.addEventListener("mousemove", this.boundOnMove, { passive: true })
    document.addEventListener("touchmove", this.boundTouchMove, { passive: true })
  }

  static update() {}

  static postUpdate() {
    this.deltaXY.set(0, 0)
    this.deltaScreenXY.set(0, 0)
    this.deltaPixelXY.set(0, 0)
    this.prevMouseXY.copy(this.mouseXY)
    this.prevMouseScreenXY.copy(this.mouseScreenXY)
    this.prevMousePixelXY.copy(this.mousePixelXY)
  }

  static destroy() {
    document.removeEventListener("mousemove", this.boundOnMove)
    document.removeEventListener("touchmove", this.boundTouchMove)
  }

  /* ---------------------------------- utils --------------------------------- */
  static _getInputXY(ev: MouseEvent | Touch, outputVector: Vector2) {
    outputVector.set(ev.clientX * this.invViewportWidth * 2 - 1, 1 - ev.clientY * this.invViewportHeight * 2)

    return outputVector
  }

  static _getInputPixelXY(ev: MouseEvent | Touch, outputVector: Vector2) {
    outputVector.set(ev.clientX, ev.clientY)
  }

  static _getInputScreenXY(ev: MouseEvent | Touch, outputVector: Vector2) {
    outputVector.set(ev.clientX * this.invViewportWidth, 1 - ev.clientY * this.invViewportHeight)
  }

  /* -------------------------------- listeners ------------------------------- */
  static _onMove(e: MouseEvent | Touch) {
    // update input coordinates
    this._getInputXY(e, this.mouseXY)
    this._getInputScreenXY(e, this.mouseScreenXY)
    this._getInputPixelXY(e, this.mousePixelXY)

    // calculate deltas
    this.deltaXY.copy(this.mouseXY).sub(this._prevMouseXY)
    this.deltaScreenXY.copy(this.mouseScreenXY).sub(this._prevMouseScreenXY)
    this.deltaPixelXY.copy(this.mousePixelXY).sub(this._prevMousePixelXY)

    // store previous positions
    this._prevMouseXY.copy(this.mouseXY)
    this._prevMouseScreenXY.copy(this.mouseScreenXY)
    this._prevMousePixelXY.copy(this.mousePixelXY)
  }

  static _getTouch(context: Input, handler: (e: MouseEvent | Touch) => void, preventDefault?: boolean) {
    return (touchEvent: TouchEvent) => {
      if (preventDefault && touchEvent.preventDefault) {
        touchEvent.preventDefault()
      }

      // Safely get touch point with null checking
      const touch = touchEvent.changedTouches?.[0] || touchEvent.touches?.[0]
      if (touch) {
        handler.call(context, touch)
      }
    }
  }
}
src/js/utils/normaliseWheel.ts
파일 저장

const PIXEL_STEP = 10
const LINE_HEIGHT = 40
const PAGE_HEIGHT = 800

export const normalizeWheel = (event: WheelEvent | Event) => {
  let sX = 0
  let sY = 0
  let pX = 0
  let pY = 0

  if ("detail" in event) {
    sY = event.detail
  }

  // if ("wheelDelta" in a) {
  //   t = -a.wheelDelta / 120
  // }

  if ("deltaY" in event) {
    sY = -event.deltaY / 120
  }

  if ("deltaX" in event) {
    sX = -event.deltaX / 120
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  if ("axis" in event && event.axis === (event as any).HORIZONTAL_AXIS) {
    sX = sY
    sY = 0
  }

  pX = sX * PIXEL_STEP
  pY = sY * PIXEL_STEP

  if ("deltaY" in event) {
    pY = event.deltaY
  }

  if ("deltaX" in event) {
    pX = event.deltaX
  }

  if ((pX || pY) && event instanceof WheelEvent && event.deltaMode) {
    if (event.deltaMode === 1) {
      pX *= LINE_HEIGHT
      pY *= LINE_HEIGHT
    } else {
      pX *= PAGE_HEIGHT
      pY *= PAGE_HEIGHT
    }
  }

  if (pX && !sX) {
    sX = pX < 1 ? -1 : 1
  }

  if (pY && !sY) {
    sY = pY < 1 ? -1 : 1
  }

  return { spinX: sX, spinY: sY, pixelX: pX, pixelY: pY }
}
src/js/utils/properties.ts
파일 저장

import GUI from "three/examples/jsm/libs/lil-gui.module.min.js"

export type QualityLevel = "low" | "medium" | "high"

export interface SnakeConfig {
  length: number
  spineSegments: number
  radialSegments: number
  texturePoints: number
  dpr: number
  enableDebug: boolean
  shaderQuality: QualityLevel
  scaleMin: number
  scaleMax: number
}

export class Properties {
  static viewportWidth = 0
  static viewportHeight = 0
  static dpr = Math.min(2, window.devicePixelRatio) ?? 1

  // Mobile detection
  static isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
  static isTouchDevice = "ontouchstart" in window || navigator.maxTouchPoints > 0

  // Quality level system
  static qualityLevel: QualityLevel = Properties.detectQualityLevel()
  static gui: GUI | null = import.meta.env.DEV ? new GUI() : null

  private static detectQualityLevel(): QualityLevel {
    // Mobile devices → low quality for performance
    if (this.isMobile) {
      console.log("[Properties] Detected quality: low (mobile device)")
      return "low"
    }

    // Desktop → always high quality, let DPR be what device supports
    console.log(`[Properties] Detected quality: high (desktop, DPR: ${this.dpr})`)
    return "high"
  }

  static getSnakeConfig(): SnakeConfig {
    const config = (() => {
      switch (this.qualityLevel) {
        case "low":
          return {
            length: 10, // Shorter snake for mobile
            spineSegments: 50, // 100 → 50 (50% reduction)
            radialSegments: 6, // 8 → 4 (50% reduction)
            texturePoints: 50, // 100 → 50 (50% reduction)
            dpr: 1, // Force 1x on mobile
            enableDebug: false,
            shaderQuality: "low" as QualityLevel,
            scaleMin: 0.05,
            scaleMax: 0.4,
          }
        case "medium":
          return {
            length: 16,
            spineSegments: 75,
            radialSegments: 6,
            texturePoints: 75,
            dpr: Math.min(1.5, this.dpr),
            enableDebug: false,
            shaderQuality: "medium" as QualityLevel,
            scaleMin: 0.1,
            scaleMax: 0.49,
          }
        case "high":
          return {
            length: 26,
            spineSegments: 100,
            radialSegments: 8,
            texturePoints: 100,
            dpr: this.dpr, // Use actual device DPR
            enableDebug: import.meta.env.DEV,
            shaderQuality: "high" as QualityLevel,
            scaleMin: 0.13,
            scaleMax: 0.65,
          }
      }
    })()

    console.log("[Properties] Snake config:", config)
    return config
  }
}
src/shaders/ball/ballFrag.glsl
파일 저장

uniform vec3 u_color;
uniform vec3 u_lightDirection;
uniform vec3 u_cameraPosition;
uniform float u_emissiveIntensity;
uniform float u_specularPower;
uniform float u_fresnelPower;

varying vec3 vNormal;
varying vec3 vPosition;

void main() {
  vec3 normal = normalize(vNormal);
  vec3 lightDir = normalize(u_lightDirection);
  vec3 viewDir = normalize(u_cameraPosition - vPosition);

  // Diffuse lighting (Lambert)
  float diffuse = max(dot(normal, lightDir), 0.0);

  // Specular highlight (Blinn-Phong)
  vec3 halfDir = normalize(lightDir + viewDir);
  float specular = pow(max(dot(normal, halfDir), 0.0), u_specularPower);

  // Fresnel rim lighting
  float fresnel = pow(1.0 - max(dot(normal, viewDir), 0.0), u_fresnelPower);

  // Combine lighting
  vec3 emissive = u_color * u_emissiveIntensity;
  vec3 diffuseColor = u_color * diffuse * 0.6;
  vec3 specularColor = vec3(1.0) * specular * 0.5;
  vec3 fresnelColor = u_color * fresnel * 0.4;

  vec3 finalColor = emissive + diffuseColor + specularColor + fresnelColor;

  gl_FragColor = vec4(finalColor, 1.0);
}
src/shaders/ball/ballVert.glsl
파일 저장

varying vec3 vNormal;
varying vec3 vPosition;

void main() {
  vec4 worldPosition = modelMatrix * vec4(position, 1.0);
  gl_Position = projectionMatrix * viewMatrix * worldPosition;
  
  vPosition = worldPosition.xyz;
  vNormal = normalize(normalMatrix * normal);
}
src/shaders/snake/snakeFrag.glsl
파일 저장

// Color uniforms
uniform vec3 u_baseColor;
uniform vec3 u_spotColor;

// Spot pattern uniforms
uniform float u_spotScale;
uniform float u_spotThreshold;
uniform float u_spotSmoothness;
uniform float u_spotIntensity;
uniform int u_spotOctaves;
uniform float u_spotPersistence;
uniform float u_spotLacunarity;

// Animation uniforms
uniform float u_timeOffset;
uniform float u_animationSpeed;

// Lighting uniforms
uniform vec3 u_cameraPosition;
uniform vec3 u_lightDirection;
uniform float u_specularPower;
uniform float u_specularIntensity;
uniform float u_fresnelPower;
uniform float u_fresnelIntensity;

// Normal perturbation uniforms
uniform float u_normalPerturbScale;
uniform float u_normalPerturbStrength;
uniform int u_normalPerturbOctaves;

// Anisotropic highlight uniforms
uniform float u_anisotropicStrength;
uniform float u_anisotropicRoughness;

// Color variation uniforms
uniform float u_bellyLightness;
uniform float u_bellyWidth;

// Varyings from vertex shader
varying vec3 vNormal;
varying float vSpineU;
varying float vTheta;
varying vec3 vWorldPos;
varying vec3 vInstancePos;

/* -------------------------------------------------------------------------- */
/*                                    noise                                   */
/* -------------------------------------------------------------------------- */
// 2D Simplex Noise functions
vec3 mod289(vec3 x) {
  return x - floor(x * (1.0 / 289.0)) * 289.0;
}

vec2 mod289(vec2 x) {
  return x - floor(x * (1.0 / 289.0)) * 289.0;
}

vec3 permute(vec3 x) {
  return mod289(((x * 34.0) + 1.0) * x);
}

float snoise(vec2 v) {
  const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);
  vec2 i = floor(v + dot(v, C.yy));
  vec2 x0 = v - i + dot(i, C.xx);
  vec2 i1;
  i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
  vec4 x12 = x0.xyxy + C.xxzz;
  x12.xy -= i1;
  i = mod289(i);
  vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
  vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0);
  m = m * m;
  m = m * m;
  vec3 x = 2.0 * fract(p * C.www) - 1.0;
  vec3 h = abs(x) - 0.5;
  vec3 ox = floor(x + 0.5);
  vec3 a0 = x - ox;
  m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);
  vec3 g;
  g.x = a0.x * x0.x + h.x * x0.y;
  g.yz = a0.yz * x12.xz + h.yz * x12.yw;
  return 130.0 * dot(m, g);
}

float octaveNoise(vec2 uv, int octaves, float persistence, float lacunarity) {
  float total = 0.0;
  float frequency = 1.0;
  float amplitude = 1.0;
  float maxValue = 0.0;

  for (int i = 0 ; i < 8 ; i++) {
    if (i >= octaves)
      break;
    total += snoise(uv * frequency) * amplitude;
    maxValue += amplitude;
    amplitude *= persistence;
    frequency *= lacunarity;
  }

  return total / maxValue;
}

/* -------------------------------------------------------------------------- */
/*                                    main                                    */
/* -------------------------------------------------------------------------- */
void main() {
  vec3 normal = normalize(vNormal);

  /* --------------------------- normal perturbation -------------------------- */
  // Add micro-scale surface detail by perturbing normals with noise
  // This simulates fine bumps/scales without adding geometry
  if (u_normalPerturbStrength > 0.0) {
    // Create UV coordinates from snake's surface (0-1 along spine, 0-1 around circumference)
    vec2 bumpCoord = vec2(vSpineU, vTheta / (2.0 * 3.14159265359)) * u_normalPerturbScale;

    // Add per-instance variation to break up tiling patterns
    vec2 instanceBumpOffset = vInstancePos.xy * 0.1;
    bumpCoord += instanceBumpOffset;

    // Sample noise to create height field for bumps
    float bumpNoise = octaveNoise(bumpCoord, u_normalPerturbOctaves, 0.5, 2.0);

    // Compute height gradient using finite differences (central difference method)
    // This tells us which direction the surface "slopes" for lighting
    float delta = 0.01;
    float bumpU = octaveNoise(bumpCoord + vec2(delta, 0.0), u_normalPerturbOctaves, 0.5, 2.0);
    float bumpV = octaveNoise(bumpCoord + vec2(0.0, delta), u_normalPerturbOctaves, 0.5, 2.0);
    vec2 gradient = vec2(bumpU - bumpNoise, bumpV - bumpNoise) / delta;

    // Build local tangent space for applying the bump perturbation
    vec3 tangent = normalize(cross(normal, vec3(0.0, 1.0, 0.0)));
    vec3 bitangent = normalize(cross(normal, tangent));

    // Apply gradient to normal (negative because height increase means normal points up)
    normal = normalize(normal - gradient.x * tangent * u_normalPerturbStrength - gradient.y * bitangent * u_normalPerturbStrength);
  }

  /* ---------------------------- base color pattern -------------------------- */
  // Create procedural spot pattern (like python or boa skin)

  // Build UV coordinates: stable as snake moves (tied to spine position, not world space)
  vec2 baseCoord = vec2(
    vSpineU,                          // 0-1 along snake's length
    vTheta / (2.0 * 3.14159265359)    // 0-1 around circumference
  );

  // Add fine per-vertex variation to prevent repetitive look within each segment
  vec2 instanceOffset = vInstancePos.xy * 0.1;

  // Scale UVs for spot frequency control
  vec2 noiseCoord = (baseCoord + instanceOffset) * u_spotScale;

  // Optional: animate pattern over time (e.g., for pulsing effect)
  if (u_animationSpeed > 0.0) {
    noiseCoord += vec2(u_timeOffset * u_animationSpeed);
  }

  // Generate organic noise pattern with multiple octaves for natural variation
  float noiseValue = octaveNoise(noiseCoord, u_spotOctaves, u_spotPersistence, u_spotLacunarity);

  // Remap noise from [-1, 1] to [0, 1] for thresholding
  noiseValue = noiseValue * 0.5 + 0.5;

  // Convert continuous noise to distinct spots with smooth transitions
  // Threshold determines spot density, smoothness controls edge softness
  float spotMask = smoothstep(u_spotThreshold - u_spotSmoothness, u_spotThreshold + u_spotSmoothness, noiseValue);

  // Mix base skin color with spot color based on mask
  vec3 color = mix(u_baseColor, u_spotColor, spotMask * u_spotIntensity);

  /* ---------------------------- belly lightening ---------------------------- */
  // Many snakes have lighter undersides - add this biological detail
  if (u_bellyLightness > 0.0) {
    // Use cosine to map circumferential angle to vertical position
    // cos(theta): 1 at top, -1 at bottom (theta wraps around snake)
    float verticalPos = cos(vTheta);

    // Create smooth gradient from belly (bottom) to back (top)
    // Remapped so 1 = belly, 0 = back
    float bellyMask = smoothstep(1.0 - u_bellyWidth, 1.0, -verticalPos + 1.0);

    // Lighten color on belly (multiplicative brightening)
    color = mix(color, color * (1.0 + u_bellyLightness), bellyMask);
  }

  /* ------------------------------- lighting --------------------------------- */
  // Apply physically-based lighting to enhance depth and realism

  vec3 viewDir = normalize(u_cameraPosition - vWorldPos);

  // 1. Fresnel rim lighting: edges glow when viewed at grazing angles
  //    Essential for the shiny, scale-like appearance of snake skin
  float fresnel = pow(1.0 - max(dot(viewDir, normal), 0.0), u_fresnelPower);
  vec3 rimLight = vec3(1.0) * fresnel * u_fresnelIntensity;

  // 2. Diffuse lighting: basic shading based on surface orientation to light
  //    Clamped to prevent pure black (ambient light fill)
  float diffuse = max(dot(normal, u_lightDirection), 0.0);
  diffuse = diffuse * 0.6 + 0.4;  // Compress dynamic range for softer shadows

  // 3. Specular highlights: shiny reflections simulating wet or scaly surface
  vec3 specular;

  if (u_anisotropicStrength > 0.0) {
    // Anisotropic specular: elongated highlights along scales (circumferential direction)
    // Real scales have directional microstructure causing stretched reflections

    // Derive surface tangent (along circumference, perpendicular to spine)
    vec3 spineDir = normalize(dFdx(vWorldPos));  // Screen-space derivative approximates spine
    vec3 tangent = normalize(cross(normal, spineDir));
    vec3 bitangent = normalize(cross(normal, tangent));

    // Half-vector between light and view (Blinn-Phong model)
    vec3 halfDir = normalize(u_lightDirection + viewDir);

    // Ward anisotropic BRDF (simplified): different roughness along tangent vs bitangent
    float dotTH = dot(tangent, halfDir);
    float dotBH = dot(bitangent, halfDir);
    float dotNH = dot(normal, halfDir);

    // Roughness controls highlight shape: stretched along tangent, tight along bitangent
    float roughnessT = u_anisotropicRoughness;        // Circumferential (stretched)
    float roughnessB = u_anisotropicRoughness * 0.1;  // Radial (tight)

    // Ward model exponent calculation
    float exponentT = dotTH * dotTH / (roughnessT * roughnessT);
    float exponentB = dotBH * dotBH / (roughnessB * roughnessB);
    float spec = exp(-(exponentT + exponentB) / max(dotNH * dotNH, 0.001));

    // Blend anisotropic with standard specular for artistic control
    float isoSpec = pow(max(dotNH, 0.0), u_specularPower);
    spec = mix(isoSpec, spec, u_anisotropicStrength);

    specular = vec3(1.0) * spec * u_specularIntensity;
  } else {
    // Standard isotropic specular (Blinn-Phong): uniform circular highlights
    vec3 halfDir = normalize(u_lightDirection + viewDir);
    float spec = pow(max(dot(normal, halfDir), 0.0), u_specularPower);
    specular = vec3(1.0) * spec * u_specularIntensity;
  }

  // Combine all lighting components
  color = color * diffuse + specular + rimLight;

  /* --------------------------------- output --------------------------------- */
  gl_FragColor = vec4(color, 1.0);
}
src/shaders/snake/snakeVert.glsl
파일 저장

// texture
uniform sampler2D u_tPosition;
uniform sampler2D u_tNormal;

// Thickness profile
uniform float u_tailRampEnd;
uniform float u_scaleMin;
uniform float u_scaleMax;
uniform float u_neckStart;
uniform float u_neckEnd;
uniform float u_neckDepth;
uniform float u_headStart;
uniform float u_headEnd;
uniform float u_headRadius;
uniform float u_headBulge;

// Cross-section radii (defines tube surface shape)
uniform float u_radiusN; // normal direction (vertical)
uniform float u_radiusB; // binormal direction (horizontal)

// Effects
uniform float u_zOffset; // belly offset in normal direction
uniform float u_twistAmount;

// Instance geometry shaping
uniform float u_instanceScaleX; // spine direction (along curve)
uniform float u_instanceScaleY; // circumferential direction
uniform float u_instanceScaleZ; // outward from surface

// Per-instance attributes
attribute float spineU; // 0..1 along spine
attribute float theta; // 0..2π around circumference

// varyings
varying vec3 vNormal;
varying float vSpineU;
varying float vTheta;
varying vec3 vWorldPos;
varying vec3 vInstancePos;

/* -------------------------------------------------------------------------- */
/*                                    main                                    */
/* -------------------------------------------------------------------------- */
void main() {
  /* ---------------------- spine position and orientation -------------------- */
  // Sample the curve's position and normal from textures
  vec3 spinePos = texture2D(u_tPosition, vec2(spineU, 0.5)).xyz;
  vec3 spineNormal = normalize(texture2D(u_tNormal, vec2(spineU, 0.5)).xyz * 2.0 - 1.0);

  // Calculate tangent using finite difference (direction along the curve)
  float delta = 0.01;
  vec3 posAhead = texture2D(u_tPosition, vec2(spineU + delta, 0.5)).xyz;
  vec3 posBehind = texture2D(u_tPosition, vec2(spineU - delta, 0.5)).xyz;
  vec3 tangent = normalize(posAhead - posBehind);

  // Binormal completes the right-handed coordinate frame
  vec3 binormal = cross(tangent, spineNormal);

  /* --------------------------- thickness profile ---------------------------- */
  // Build the snake's thickness from multiple components (0..1 range)

  // 1. Tail ramp: fade in from 0 at the tail tip
  float tailRamp = smoothstep(0.0, u_tailRampEnd, spineU);

  // 2. Neck pinch: narrow section before the head
  float neckMid = (u_neckStart + u_neckEnd) * 0.5;
  float neckDown = smoothstep(u_neckStart, neckMid, spineU);
  float neckUp = smoothstep(neckMid, u_neckEnd, spineU);
  float neckPinch = 1.0 - u_neckDepth * neckDown * (1.0 - neckUp);

  // 3. Head bulge: localized expansion in the head region
  float headMid = (u_headStart + u_headEnd) * 0.5;
  float headRampUp = smoothstep(u_headStart, headMid, spineU);
  float headRampDown = smoothstep(headMid, u_headEnd, spineU);
  float headBulge = u_headBulge * headRampUp * (1.0 - headRampDown);

  // 4. Head base radius: transition from body thickness to head size
  float headBaseRadius = neckPinch * mix(1.0, u_headRadius, headRampUp);

  // 5. Tip closure: taper to zero at the very end to close the head
  float tipClosure = 1.0 - smoothstep(0.97, 1.0, spineU);

  // Combine all thickness components into final value
  float combinedThickness = tailRamp * (headBaseRadius + headBulge) * tipClosure;
  float scale = max(mix(u_scaleMin, u_scaleMax, combinedThickness), 0.001);

  /* -------------------------- tube surface position ------------------------- */
  // Add twist effect that accumulates along the spine
  float twistedTheta = theta + spineU * u_twistAmount;

  // Scale the elliptical cross-section radii
  float radiusNormal = scale * u_radiusN;
  float radiusBinormal = scale * u_radiusB;

  // Calculate offset from spine to tube surface (elliptical cross-section)
  vec3 ringOffset = spineNormal * cos(twistedTheta) * radiusNormal + binormal * sin(twistedTheta) * radiusBinormal;

  // Apply belly offset (pushes surface down slightly for realism)
  ringOffset += spineNormal * combinedThickness * u_zOffset;

  vec3 surfacePos = spinePos + ringOffset;

  /* --------------------------- tube surface normal -------------------------- */
  // Surface normal for the elliptical tube (swapped radii for correct curvature)
  vec3 surfaceNormal = normalize(spineNormal * cos(twistedTheta) * radiusBinormal + binormal * sin(twistedTheta) * radiusNormal);

  /* -------------------- local coordinate frame at surface ------------------- */
  // Build orthonormal frame aligned to tube surface
  vec3 circumTangent = normalize(cross(surfaceNormal, tangent));
  vec3 spineDirection = normalize(cross(circumTangent, surfaceNormal));

  // Create transformation matrix: X=along spine, Y=around circumference, Z=outward from surface
  mat3 surfaceFrame = mat3(spineDirection, circumTangent, surfaceNormal);

  /* ------------------------------ final position ----------------------------- */
  // Scale the instanced geometry based on position along snake
  vec3 scaledPos = vec3(
    position.x * scale * u_instanceScaleX,
    position.y * scale * u_instanceScaleY,
    position.z * scale * u_instanceScaleZ
  );

  // Transform from local space to tube surface, then to world space
  vec3 worldPos = surfacePos + surfaceFrame * scaledPos;

  /* ------------------------------- final normal ------------------------------ */
  // Apply inverse scale to normal (maintains correct lighting under non-uniform scaling)
  vec3 correctedNormal = normalize(vec3(
    normal.x / u_instanceScaleX,
    normal.y / u_instanceScaleY,
    normal.z / u_instanceScaleZ
  ));

  vec3 worldNormal = surfaceFrame * correctedNormal;
  vNormal = normalize((modelMatrix * vec4(worldNormal, 0.0)).xyz);

  /* --------------------------------- output --------------------------------- */
  gl_Position = projectionMatrix * modelViewMatrix * vec4(worldPos, 1.0);

  // Pass data to fragment shader
  vSpineU = spineU;
  vTheta = theta;
  vWorldPos = (modelMatrix * vec4(worldPos, 1.0)).xyz;
  vInstancePos = position;
}
src/vite-env.d.ts
파일 저장

/// <reference types="vite/client" />

declare module "*.glsl?raw" {
  const content: string
  export default content
}

declare module "*.vert?raw" {
  const content: string
  export default content
}

declare module "*.frag?raw" {
  const content: string
  export default content
}
vite.config.js
파일 저장

import { defineConfig } from "vite"

export default defineConfig({
  base: "./",
  root: "./src",
  publicDir: "../public",
  build: {
    outDir: "../dist",
    emptyOutDir: true,
    minify: "terser",
    terserOptions: {
      compress: {
        drop_console: true, // Remove console.log in production
        drop_debugger: true,
        pure_funcs: ["console.log", "console.warn", "console.info"],
      },
    },
    rollupOptions: {
      output: {
        manualChunks: {
          three: ["three"], // Separate Three.js into its own chunk for better caching
        },
      },
    },
    sourcemap: false, // Disable sourcemaps in production for smaller bundle
    reportCompressedSize: true,
  },
  server: {
    port: 5173,
    open: true,
  },
})
Media credits and license evidence실행 안내·자료
파일 저장

README.md
## Credits


## 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.182.0 — LICENSE
The MIT License

Copyright © 2010-2025 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.


simplex-noise@4.0.3 — LICENSE
MIT License

Copyright (c) 2018 Jonas Wagner

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.