Codrops 원본
Building an Endless Procedural Snake with Three.js and WebGL
배경 · MIT
갤러리 안에서는 화면을 벗어나거나 움직임을 멈추면 예제도 닫힙니다. 다시 재생할 때는 처음부터 시작해요.
SOURCE FILES
원본 코드 읽기
수집한 원본 소스와 실행 안내를 함께 제공합니다.
.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,
},
})
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.
BEHIND THE EXAMPLE
이 장면을 만드는 원리
연결 곡선에서 이어지는 단면 방향
화면 크기·입력·동시 작업을 고정하고 실제 렌더링 실패와 비용을 관찰합니다.
- 이 예제에서는
- EndlessCurve는 이전 접선과 법선을 다음 곡선으로 이어 주며 평행 이동 회전으로 프레임을 계산합니다. 거의 반대인 접선에는 별도 회전축을 선택합니다.
코드와 함께 확인하기
코드에서 찾기
parallelTransportEndlessCurve.ts접선 내적·외적으로 회전축과 각도를 정하고 법선을 회전합니다.
직접 해보기
직선에 가까운 구간과 급격히 방향이 바뀌는 연결부를 비교합니다.
살펴볼 변화몸체의 단면이 갑자기 뒤집히거나 연결부가 벌어지는지 확인해야 합니다.
