Codrops 원본

Building an Interactive Wave Propagation Cube Grid with Three.js

배경 · MIT

배경 더 보기
ORIGINAL PREVIEW
Building an Interactive Wave Propagation Cube Grid with Three.js 정적 미리보기

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

큰 화면으로 보기 새 탭

SOURCE FILES

원본 코드 읽기

14개 파일

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

src/ThreeJS/Camera.js
파일 저장

import * as THREE from "three";
import Orchestrator from "./Orchestrator.js";
import GUI from "lil-gui";

export default class Camera {
    constructor() {
        this.orchestrator = new Orchestrator();
        this.sizes = this.orchestrator.sizes;
        this.scene = this.orchestrator.scene;
        this.canvas = this.orchestrator.canvas;

        // Orbit parameters
        this.radius = 12;

        // Max mouse influence in radians
        // mouse Y → rotation around X axis (up/down tilt)
        // mouse X → rotation around Z axis (left/right orbit)
        this.alphaRange = Math.PI * 0.03; // ±~14° around X
        this.betaRange = Math.PI * 0.05; // ±~22° around Z

        // Normalized mouse [-1, 1] and its lerped counterpart
        this.mouse = new THREE.Vector2(0, 0);
        this.lerpedMouse = new THREE.Vector2(0, 0);

        this.setInstance();
        this.setMouseListener();
        this.setGUI();
    }

    setInstance() {
        this.instance = new THREE.PerspectiveCamera(
            40,
            this.sizes.width / this.sizes.height,
            0.1,
            200,
        );
        this._updatePosition(0, 0);
        this.scene.add(this.instance);
    }

    setMouseListener() {
        window.addEventListener("mousemove", (e) => {
            this.mouse.x = (e.clientX / this.sizes.width) * 2 - 1;
            this.mouse.y = -(e.clientY / this.sizes.height) * 2 + 1;
        });
    }

    _updatePosition(mx, my) {
        // α: rotation around X axis (mouse Y)
        // β: rotation around Z axis (mouse X)
        const alpha = my * this.alphaRange;
        const beta = mx * this.betaRange;

        // Start at (0, r, 0), apply X rotation then Z rotation:
        // After X: (0, r·cosα, r·sinα)
        // After Z: (-r·cosα·sinβ, r·cosα·cosβ, r·sinα)
        this.instance.position.set(
            -this.radius * Math.cos(alpha) * Math.sin(beta),
            this.radius * Math.cos(alpha) * Math.cos(beta),
            this.radius * Math.sin(alpha),
        );
        this.instance.up.set(0, 0, -1);
        this.instance.lookAt(0, 0, 0);
    }

    resize() {
        this.instance.aspect = this.sizes.width / this.sizes.height;
        this.instance.updateProjectionMatrix();
    }

    update() {
        // Lerp mouse toward actual cursor position
        this.lerpedMouse.x += (this.mouse.x - this.lerpedMouse.x) * 0.04;
        this.lerpedMouse.y += (this.mouse.y - this.lerpedMouse.y) * 0.04;
        this._updatePosition(this.lerpedMouse.x, this.lerpedMouse.y);
        // this.controls.update();
    }

    setGUI() {
        this.gui = this.orchestrator.debug.ui;
        if (!this.gui) return;
        const camFolder = this.gui.addFolder("Camera");

        camFolder.add(this, "radius", 10, 20, 0.01).name("Distance");
    }
}
src/ThreeJS/Effects/MouseTrail.js
파일 저장

import * as THREE from "three";
import Orchestrator from "../Orchestrator.js";

// Maximum number of trail points kept alive at once.
// Must match the literal "128" used in the vertex shader texture lookup.
const MAX_TRAIL = 128;

/**
 * MouseTrail
 *
 * Records a mouse trail in world space and uploads it every frame as a
 * MAX_TRAIL×1 RGBA float DataTexture.  Each texel encodes one trail point:
 *   .r = world X
 *   .g = world Z
 *   .b = age (seconds since the point was created)
 *   .a = unused
 *
 * The vertex shader in World.js reads this texture and, for every cube,
 * sums the wave contributions from each live trail point:
 *   - an outward-expanding Gaussian envelope centred on the wavefront
 *   - cosine oscillation relative to the wavefront position
 *   - exponential time-fade and 1/(1+dist) distance attenuation
 *
 * Public API
 * ----------
 *   uniforms  – object whose entries can be directly assigned to
 *               shader.uniforms inside onBeforeCompile
 *   params    – tweak { fadeTime, trailSpacing };
 *               GUI onChange handlers should also update
 *               the corresponding uniform.value (see World.js setGUI)
 *   update(delta)  – call once per frame with delta in seconds
 *   dispose()      – removes event listeners and frees the texture
 */
export default class MouseTrail {
    constructor(bounds) {
        this.orchestrator = new Orchestrator();
        this.camera = this.orchestrator.camera.instance;
        this.canvas = this.orchestrator.canvas;
        this.bounds = bounds;

        this.params = {
            fadeTime: 2.0, // seconds for amplitude to fall to ~37 %
            trailSpacing: 0.1, // minimum world-unit distance between trail points
        };

        this.trail = []; // [ { x, z, age } ]
        this.lastPoint = null;

        // Timer-related properties for random points
        this.timeSinceLastMove = 0;
        this.randomPointTimer = 0;
        this.isPlacingRandomPoints = true; // Start with random points immediately
        this.randomPointStrength = 0.8; // gets assigned as distDelta, with a small random variation

        this.mouseCoords = new THREE.Vector2();
        this.raycaster = new THREE.Raycaster();

        // Invisible horizontal plane for pointer → world-space raycasting
        this.rayPlane = new THREE.Mesh(
            new THREE.PlaneGeometry(bounds, bounds),
            new THREE.MeshBasicMaterial({
                side: THREE.DoubleSide,
                visible: false,
            }),
        );
        this.rayPlane.rotation.x = -Math.PI / 2;
        this.rayPlane.updateMatrixWorld(true);

        // DataTexture (MAX_TRAIL × 1, RGBA float): trail data for the shader
        this.trailData = new Float32Array(MAX_TRAIL * 4);
        this.trailTexture = new THREE.DataTexture(
            this.trailData,
            MAX_TRAIL,
            1,
            THREE.RGBAFormat,
            THREE.FloatType,
        );
        this.trailTexture.needsUpdate = true;

        // Uniform objects — assigned by reference in World.js onBeforeCompile
        // so mutations here are automatically reflected in the shader each frame.
        this._uniforms = {
            uTrailTexture: { value: this.trailTexture },
            uTrailCount: { value: 0 },
            uFadeTime: { value: this.params.fadeTime },
        };

        // Pointer event rect caching
        this.rect = this.canvas.getBoundingClientRect();
        this.orchestrator.sizes.emitter.on("resize", () => {
            this.rect = this.canvas.getBoundingClientRect();
        });

        this.bindPointerEvents();
    }

    // ─── Public API ──────────────────────────────────────────────────────────

    get uniforms() {
        return this._uniforms;
    }

    /**
     * Age all trail points, prune expired ones, and upload the updated data
     * to the GPU texture.
     * @param {number} delta  Frame time in seconds.
     */
    update(delta) {
        // Points survive for fadeTime * 4 seconds; at that age the shader
        // fade factor exp(-4) ≈ 0.018 makes them visually negligible.
        const expiry = this.params.fadeTime * 4;

        for (let i = this.trail.length - 1; i >= 0; i--) {
            this.trail[i].age += delta;
            if (this.trail[i].age > expiry) {
                this.trail.splice(i, 1);
            }
        }

        // Handle inactivity and random point placement
        this.timeSinceLastMove += delta;

        // Start placing random points after 3 seconds of inactivity
        if (this.timeSinceLastMove >= 3.0 && !this.isPlacingRandomPoints) {
            this.isPlacingRandomPoints = true;
            this.randomPointTimer = 0;
        }

        // Place random points every 1.5 seconds when in random mode
        if (this.isPlacingRandomPoints) {
            this.randomPointTimer += delta;
            if (this.randomPointTimer >= 1.5) {
                this.addRandomPoint();
                this.randomPointTimer = 0;
            }
        }

        // Upload the latest MAX_TRAIL live points to the texture
        const count = Math.min(this.trail.length, MAX_TRAIL);

        if (count > 0 || this._uniforms.uTrailCount.value > 0) {
            for (let i = 0; i < count; i++) {
                const ti = i * 4;
                this.trailData[ti] = this.trail[i].x;
                this.trailData[ti + 1] = this.trail[i].z;
                this.trailData[ti + 2] = this.trail[i].age;
                this.trailData[ti + 3] = this.trail[i].distDelta;
            }
            this.trailTexture.needsUpdate = true;
            this._uniforms.uTrailCount.value = count;
        }
    }

    dispose() {
        this.canvas.removeEventListener("pointermove", this.onPointerMove);
        this.trailTexture.dispose();
    }

    // ─── Private ─────────────────────────────────────────────────────────────

    bindPointerEvents() {
        this.onPointerMove = (e) => {
            this.mouseCoords.set(
                ((e.clientX - this.rect.left) / this.rect.width) * 2 - 1,
                -((e.clientY - this.rect.top) / this.rect.height) * 2 + 1,
            );

            this.raycaster.setFromCamera(this.mouseCoords, this.camera);
            const hits = this.raycaster.intersectObject(this.rayPlane);
            if (hits.length === 0) return;

            const { x, z } = hits[0].point;

            let distDelta = 0;
            // Only append a new point when the mouse has moved far enough
            if (this.lastPoint) {
                const dx = x - this.lastPoint.x;
                const dz = z - this.lastPoint.z;
                distDelta = Math.sqrt(dx * dx + dz * dz);
                if (distDelta < this.params.trailSpacing) return;
            }

            // Evict the oldest point if we're at capacity
            if (this.trail.length >= MAX_TRAIL) {
                this.trail.shift();
            }

            this.trail.push({ x, z, age: 0, distDelta });
            this.lastPoint = { x, z };

            // Reset timers when mouse moves
            this.timeSinceLastMove = 0;
            this.isPlacingRandomPoints = false;
            this.randomPointTimer = 0;
        };

        this.canvas.addEventListener("pointermove", this.onPointerMove);
    }

    addRandomPoint() {
        const x = (Math.random() * 0.5 - 0.25) * this.bounds;
        const z = (Math.random() * 0.5 - 0.25) * this.bounds;
        const distDelta = this.randomPointStrength + Math.random() * 0.2;

        if (this.trail.length >= MAX_TRAIL) {
            this.trail.shift();
        }

        this.trail.push({ x, z, age: 0, distDelta });
    }
}
함께 쓰는 파일 12개 보기
src/ThreeJS/Effects/VignetteRGBShiftShader.js
파일 저장

import { ShaderPass } from "three/addons/postprocessing/ShaderPass.js";

const VignetteRGBShiftShader = {
    uniforms: {
        tDiffuse: { value: null },
        shiftAmount: { value: 0.005 }, // Maximum color split intensity
        vignetteRadius: { value: 0.3 }, // Where the effect starts (0.0 to 1.0)
        vignetteSoftness: { value: 0.3 }, // Falloff smoothness of the effect
    },

    vertexShader: `
    varying vec2 vUv;
    void main() {
      vUv = uv;
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
  `,

    fragmentShader: `
    uniform sampler2D tDiffuse;
    uniform float shiftAmount;
    uniform float vignetteRadius;
    uniform float vignetteSoftness;
    varying vec2 vUv;

    void main() {
      // 1. Calculate distance from the center of the screen (0.5, 0.5)
      vec2 center = vec2(0.5);
      float dist = distance(vUv, center);
      float horzQuadrant = sign(vUv.x - center.x); // -1 on left, +1 on right
      float vertQuadrant = sign(vUv.y - center.y); // -1 on bottom, +1 on top

      // 2. Create the vignette mask (0.0 at center, closer to 1.0 at corners)
      float vignetteFactor = smoothstep(vignetteRadius, vignetteRadius + vignetteSoftness, dist);

      // 3. Scale the shift intensity based on the vignette mask
      float currentShift = shiftAmount * vignetteFactor;

      // 4. Sample the color channels with the dynamic shift
      // Shifts Red up/right and Blue down/left based on the vignette intensity
      float r = texture2D(tDiffuse, vUv + vec2(currentShift * horzQuadrant, currentShift * vertQuadrant)).r;
      float g = texture2D(tDiffuse, vUv).g;
      float b = texture2D(tDiffuse, vUv - vec2(currentShift * horzQuadrant, currentShift * vertQuadrant)).b;

      // 5. Apply a standard darkening vignette overlay (Optional)
      // Darkens the corners to match the chromatic aberration
      float darken = 1.0 - vignetteFactor * 0.5; // Adjust 0.5 to change darkness

      gl_FragColor = vec4(vec3(r, g, b) * darken, 1.0);
    }
  `,
};

export { VignetteRGBShiftShader };
src/ThreeJS/Orchestrator.js
파일 저장

import * as THREE from "three";
import Stats from "stats.js";

import Debug from "./Utils/Debug.js";
import Sizes from "./Utils/Sizes.js";
import Camera from "./Camera.js";
import Renderer from "./Renderer.js";
import Stage from "./Stage.js";

let instance = null;

export default class Orchestrator {
    constructor(_canvas) {
        // Singleton
        if (instance) {
            return instance;
        }
        instance = this;

        // Options
        this.canvas = _canvas;

        // Setup
        this.debug = new Debug();
        this.sizes = new Sizes();
        this.scene = new THREE.Scene();
        this.camera = new Camera();
        this.renderer = new Renderer();
        this.clock = new THREE.Timer();
        // this makes use of the Page Visibility API to avoid large time delta values when the app is inactive (e.g. tab switched or browser hidden).
        this.clock.connect(document);

        // The main stage of the threejs experience,
        // where the bulk of your scene objects and logic will live.
        this.stage = new Stage();

        // Stats
        if (this.debug.active) {
            this.stats = new Stats();
            this.stats.showPanel(0); // 0: FPS, 1: MS, 2: MB
            this.stats.dom.style.left = "0px";
            this.stats.dom.style.top = "0px";
            document.body.appendChild(this.stats.dom);
        }

        // Resize event
        this.sizes.emitter.on("resize", () => {
            this.resize();
        });

        // Setup the animation loop
        // Always define the animation loop with this method and not manually with requestAnimationFrame() for best compatibility.
        this.renderer.instance.setAnimationLoop(this.animate.bind(this));
    }

    resize() {
        this.camera.resize();
        this.renderer.resize();
    }

    animate() {
        this.clock.update();

        // get the time delta and elapsed time in seconds
        const delta = this.clock.getDelta();
        const elapsed = this.clock.getElapsed();

        this.update(elapsed, delta);
    }

    update(elapsed, delta) {
        if (this.debug.active) this.stats.begin();
        this.camera.update();
        this.stage.update(delta);
        this.renderer.update();
        if (this.debug.active) this.stats.end();
    }

    destroy() {
        this.clock.disconnect();
        this.clock.dispose();
        this.sizes.emitter.off("resize");

        // Traverse the whole scene
        this.scene.traverse((child) => {
            // Test if it's a mesh
            if (child instanceof THREE.Mesh) {
                child.geometry.dispose();

                // Loop through the material properties
                for (const key in child.material) {
                    const value = child.material[key];

                    // Test if there is a dispose function
                    if (value && typeof value.dispose === "function") {
                        value.dispose();
                    }
                }
            }
        });

        this.camera.controls.dispose();
        this.renderer.instance.dispose();

        if (this.debug.active) this.debug.ui.destroy();
    }
}
src/ThreeJS/Renderer.js
파일 저장

import * as THREE from "three";
import Orchestrator from "./Orchestrator.js";
import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
import { ShaderPass } from "three/addons/postprocessing/ShaderPass.js";
import { OutputPass } from "three/addons/postprocessing/OutputPass.js";
import { VignetteRGBShiftShader } from "./Effects/VignetteRGBShiftShader.js";

export default class Renderer {
    constructor() {
        this.orchestrator = new Orchestrator();
        this.canvas = this.orchestrator.canvas;
        this.sizes = this.orchestrator.sizes;
        this.scene = this.orchestrator.scene;
        this.camera = this.orchestrator.camera;

        this.setInstance();
        this.setPostProcessing();
        this.setGUI();
    }

    setInstance() {
        this.instance = new THREE.WebGLRenderer({
            canvas: this.canvas,
            antialias: true,
        });
        this.instance.toneMapping = THREE.ACESFilmicToneMapping;
        this.instance.toneMappingExposure = 1.95;
        this.instance.shadowMap.enabled = true;
        this.instance.shadowMap.type = THREE.PCFShadowMap;
        this.instance.setClearColor("#808080");
        this.instance.setSize(this.sizes.width, this.sizes.height);
        this.instance.setPixelRatio(this.sizes.pixelRatio);
    }

    setPostProcessing() {
        this.composer = new EffectComposer(this.instance);
        this.renderPass = new RenderPass(this.scene, this.camera.instance);
        this.composer.addPass(this.renderPass);

        this.vignetteRGBShiftPass = new ShaderPass(VignetteRGBShiftShader);
        this.vignetteRGBShiftPass.uniforms.shiftAmount.value = 0.005; // Adjust the intensity of the RGB shift
        this.vignetteRGBShiftPass.uniforms.vignetteRadius.value = 0.3; // Adjust where the effect starts (0.0 to 1.0)
        this.vignetteRGBShiftPass.uniforms.vignetteSoftness.value = 0.3; // Adjust the falloff smoothness of the effect
        this.composer.addPass(this.vignetteRGBShiftPass);

        this.outputPass = new OutputPass();
        this.composer.addPass(this.outputPass);
    }

    setGUI() {
        this.gui = this.orchestrator.debug.ui;
        if (!this.gui) return;
        const ppFolder = this.gui.addFolder("Post Processing");

        ppFolder
            .add(
                this.vignetteRGBShiftPass.uniforms.shiftAmount,
                "value",
                0,
                0.02,
                0.001,
            )
            .name("Shift Amount");
        ppFolder
            .add(
                this.vignetteRGBShiftPass.uniforms.vignetteRadius,
                "value",
                0,
                1,
                0.01,
            )
            .name("Vignette Radius");
        ppFolder
            .add(
                this.vignetteRGBShiftPass.uniforms.vignetteSoftness,
                "value",
                0,
                1,
                0.01,
            )
            .name("Vignette Softness");
    }

    resize() {
        this.instance.setSize(this.sizes.width, this.sizes.height);
        this.instance.setPixelRatio(this.sizes.pixelRatio);
        this.composer.setSize(this.sizes.width, this.sizes.height);
        this.composer.setPixelRatio(this.sizes.pixelRatio);
    }

    update() {
        // render via composer when you have post-processing effects, otherwise use the renderer directly
        this.composer.render();
        // this.instance.render(this.scene, this.camera.instance);
    }
}
src/ThreeJS/Stage.js
파일 저장

import * as THREE from "three";
import GUI from "lil-gui";
import Orchestrator from "./Orchestrator.js";
import MouseTrail from "./Effects/MouseTrail.js";

export default class Stage {
    constructor() {
        this.orchestrator = new Orchestrator();
        this.scene = this.orchestrator.scene;

        this.gridSize = 40;
        this.cubeWidth = 0.8;
        this.cubeHeight = 3;
        this.params = {
            gap: 0.01,
            waveAmplitude: 0.4,
            waveSpeed: 6.0, // world units / second
            waveFrequency: 1.2, // radians / world unit (spatial oscillation)
            waveWidth: 3.0, // Gaussian half-width of the wave ring (world units)
            waveJitter: 0.2,
            waveMaxHeight: 0.4,
            colorBase: "#ffffff",
            colorHigh: "#0055ff",
        };
        this.scene.background = new THREE.Color(
            this.params.colorBase,
        ).multiplyScalar(0.5); // half of uColorBase for a more subtle background

        this.lightingParams = {
            ambientColor: "#ffffff",
            ambientIntensity: 0.5,
            directionalColor: "#ffffff",
            directionalIntensity: 4.0,
            directional2Color: "#ffffff",
            directional2Intensity: 1.0,
        };

        // Physical world-unit footprint of the grid (centre-to-centre span).
        this.bounds = this.gridSize * (this.cubeWidth + this.params.gap);

        this.shaderRef = null;

        this.setLighting();
        this.setGrid();
        this.mouseTrail = new MouseTrail(this.bounds);
        this.setGUI();
    }

    setLighting() {
        const lp = this.lightingParams;

        this.ambientLight = new THREE.AmbientLight(
            lp.ambientColor,
            lp.ambientIntensity,
        );
        this.scene.add(this.ambientLight);

        this.directionalLight = new THREE.DirectionalLight(
            lp.directionalColor,
            lp.directionalIntensity,
        );
        this.directionalLight.position.set(-20, 10, 6);
        this.directionalLight.castShadow = true;
        this.directionalLight.shadow.mapSize.set(1024, 1024);
        this.directionalLight.shadow.radius = 6;
        this.directionalLight.shadow.camera.near = 0.1;
        this.directionalLight.shadow.camera.far = 60;
        this.directionalLight.shadow.camera.left = -22;
        this.directionalLight.shadow.camera.right = 22;
        this.directionalLight.shadow.camera.top = 22;
        this.directionalLight.shadow.camera.bottom = -22;
        this.directionalLight.shadow.bias = 0.0001;
        this.scene.add(this.directionalLight);

        this.directionalLight2 = new THREE.DirectionalLight(
            lp.directional2Color,
            lp.directional2Intensity,
        );
        this.directionalLight2.position.set(10, 5, -3);
        this.directionalLight2.castShadow = false;
        this.scene.add(this.directionalLight2);

        // Add camera helper to visualize shadow camera frustum
        this.shadowCameraHelper = new THREE.CameraHelper(
            this.directionalLight.shadow.camera,
        );
        this.shadowCameraHelper.visible = false;
        this.scene.add(this.shadowCameraHelper);
    }

    // This is shared between the main shader and the depth shader to ensure consistent wave deformation in both passes.
    overrideVertexShader(vertexShader) {
        return vertexShader
            .replace(
                "#include <common>",
                `#include <common>
                varying float vHeight;
                attribute vec2 aOffset;
                uniform sampler2D uTrailTexture;
                uniform int       uTrailCount;
                uniform float     uWaveSpeed;
                uniform float     uWaveFreq;
                uniform float     uWaveWidth;
                uniform float     uFadeTime;
                uniform float     uAmplitude;
                uniform float     uJitter;
                uniform float     uMaxHeight;

                // Deterministic per-instance hash → two values in [-0.5, 0.5].
                // Stable across frames; depends only on world position.
                vec2 hash2( vec2 p ) {
                    p = vec2(
                        dot( p, vec2( 127.1, 311.7 ) ),
                        dot( p, vec2( 269.5, 183.3 ) )
                    );
                    return fract( sin( p ) * 43758.5453123 ) - 0.5;
                }`,
            )
            .replace(
                "#include <begin_vertex>",
                `#include <begin_vertex>

                vHeight = 0.0;

                if ( position.y > 0.0 ) {
                    vec2 jitter  = hash2( aOffset ) * uJitter;
                    vec2 worldXZ = aOffset + jitter;
                    float waveHeight  = 0.0;
                    float totalWeight = 0.0;

                    for ( int i = 0; i < uTrailCount; i++ ) {
                        // texel layout: (worldX, worldZ, age, distDelta)
                        vec4 td = texture2D(
                            uTrailTexture,
                            vec2( ( float(i) + 0.5 ) / 128.0, 0.5 )
                        );
                        float dist      = length( worldXZ - td.rg );
                        float wavefront = uWaveSpeed * td.b;
                        float relDist   = dist - wavefront;

                        // Gaussian envelope centred on the expanding wavefront
                        float window = exp( -( relDist * relDist ) / ( uWaveWidth * uWaveWidth ) );
                        // Exponential time-fade + distance attenuation
                        float fade   = exp( -td.b / uFadeTime );
                        float atten  = 1.0 / ( 1.0 + dist * 0.1 );
                        float weight = fade * window * atten * td.a; // td.a is distDelta, used to weaken waves from closely spaced trail points

                        waveHeight  += weight * cos( uWaveFreq * relDist );
                        totalWeight += weight;
                    }

                    // Weighted average: overlapping waves average rather than stack,
                    // cancelling chaotic superposition while preserving single-wave peaks.
                    waveHeight /= max( totalWeight, 1.0 );

                    float displacement = clamp( waveHeight * uAmplitude, -uMaxHeight, uMaxHeight );
                    transformed.y += displacement;
                    vHeight = displacement;
                }`,
            );
    }

    setGrid() {
        const count = this.gridSize * this.gridSize;
        const geometry = new THREE.BoxGeometry(
            this.cubeWidth,
            this.cubeHeight,
            this.cubeWidth,
        );

        // Per-instance XZ world position passed to the vertex shader
        this.offsetAttribute = new THREE.InstancedBufferAttribute(
            new Float32Array(count * 2),
            2,
        );
        geometry.setAttribute("aOffset", this.offsetAttribute);

        const material = new THREE.MeshPhongMaterial({ color: 0xffffff });

        material.onBeforeCompile = (shader) => {
            // Attach trail-wave uniforms by reference so MouseTrail.update()
            // mutations are automatically reflected each frame without extra work here.
            const mu = this.mouseTrail.uniforms;
            shader.uniforms.uTrailTexture = mu.uTrailTexture;
            shader.uniforms.uTrailCount = mu.uTrailCount;
            shader.uniforms.uFadeTime = mu.uFadeTime;
            shader.uniforms.uWaveSpeed = { value: this.params.waveSpeed };
            shader.uniforms.uWaveFreq = { value: this.params.waveFrequency };
            shader.uniforms.uWaveWidth = { value: this.params.waveWidth };
            shader.uniforms.uAmplitude = { value: this.params.waveAmplitude };
            shader.uniforms.uJitter = { value: this.params.waveJitter };
            shader.uniforms.uMaxHeight = { value: this.params.waveMaxHeight };
            shader.uniforms.uColorBase = {
                value: new THREE.Color(this.params.colorBase),
            };
            shader.uniforms.uColorHigh = {
                value: new THREE.Color(this.params.colorHigh),
            };

            shader.vertexShader = this.overrideVertexShader(
                shader.vertexShader,
            );

            shader.fragmentShader = shader.fragmentShader
                .replace(
                    "#include <common>",
                    `#include <common>
                    varying float vHeight;
                    uniform vec3  uColorBase;
                    uniform vec3  uColorHigh;
                    uniform float uMaxHeight;`,
                )
                .replace(
                    "#include <color_fragment>",
                    `#include <color_fragment>
                    float t = clamp( vHeight / uMaxHeight, 0.0, 1.0 );
                    diffuseColor.rgb = mix( uColorBase, uColorHigh, t );`,
                );

            this.shaderRef = shader;
        };

        const depthMaterial = new THREE.MeshDepthMaterial();

        depthMaterial.onBeforeCompile = (shader) => {
            const mu = this.mouseTrail.uniforms;
            shader.uniforms.uTrailTexture = mu.uTrailTexture;
            shader.uniforms.uTrailCount = mu.uTrailCount;
            shader.uniforms.uFadeTime = mu.uFadeTime;
            shader.uniforms.uWaveSpeed = { value: this.params.waveSpeed };
            shader.uniforms.uWaveFreq = { value: this.params.waveFrequency };
            shader.uniforms.uWaveWidth = { value: this.params.waveWidth };
            shader.uniforms.uAmplitude = { value: this.params.waveAmplitude };
            shader.uniforms.uJitter = { value: this.params.waveJitter };
            shader.uniforms.uMaxHeight = { value: this.params.waveMaxHeight };

            shader.vertexShader = this.overrideVertexShader(
                shader.vertexShader,
            );
        };

        this.instancedMesh = new THREE.InstancedMesh(geometry, material, count);
        this.instancedMesh.customDepthMaterial = depthMaterial;
        this.instancedMesh.castShadow = true;
        this.instancedMesh.receiveShadow = true;
        this.scene.add(this.instancedMesh);
        this.updateGrid();
    }

    updateGrid() {
        const dummy = new THREE.Object3D();
        const spacing = this.cubeWidth + this.params.gap;
        const offset = ((this.gridSize - 1) * spacing) / 2;

        for (let i = 0; i < this.gridSize; i++) {
            for (let j = 0; j < this.gridSize; j++) {
                const index = i * this.gridSize + j;
                const x = i * spacing - offset;
                const z = j * spacing - offset;
                dummy.position.set(x, 0, z);
                dummy.updateMatrix();
                this.instancedMesh.setMatrixAt(index, dummy.matrix);
                this.offsetAttribute.setXY(index, x, z);
            }
        }
        this.instancedMesh.instanceMatrix.needsUpdate = true;
        this.offsetAttribute.needsUpdate = true;
    }

    setGUI() {
        this.gui = this.orchestrator.debug.ui;
        if (!this.gui) return;

        this.gui
            .add(this.params, "gap", 0, 1, 0.01)
            .name("Gap")
            .onChange(() => this.updateGrid());

        this.gui
            .add(this.params, "waveAmplitude", 0, 10, 0.01)
            .name("Wave Amplitude")
            .onChange(() => {
                if (this.shaderRef)
                    this.shaderRef.uniforms.uAmplitude.value =
                        this.params.waveAmplitude;
            });

        this.gui
            .add(this.params, "waveSpeed", 1, 20, 0.1)
            .name("Wave Speed")
            .onChange((v) => {
                if (this.shaderRef) {
                    this.shaderRef.uniforms.uWaveSpeed.value = v;
                }
            });

        this.gui
            .add(this.params, "waveFrequency", 0.1, 5, 0.05)
            .name("Wave Frequency")
            .onChange((v) => {
                if (this.shaderRef) {
                    this.shaderRef.uniforms.uWaveFreq.value = v;
                }
            });

        this.gui
            .add(this.params, "waveWidth", 0.5, 10, 0.1)
            .name("Wave Width")
            .onChange((v) => {
                if (this.shaderRef) {
                    this.shaderRef.uniforms.uWaveWidth.value = v;
                }
            });

        this.gui
            .add(this.params, "waveMaxHeight", 0, this.cubeHeight, 0.05)
            .name("Wave Max Height")
            .onChange(() => {
                if (this.shaderRef)
                    this.shaderRef.uniforms.uMaxHeight.value =
                        this.params.waveMaxHeight;
            });

        this.gui
            .add(this.params, "waveJitter", 0, 2, 0.01)
            .name("Wave Jitter")
            .onChange(() => {
                if (this.shaderRef)
                    this.shaderRef.uniforms.uJitter.value =
                        this.params.waveJitter;
            });

        this.gui
            .addColor(this.params, "colorBase")
            .name("Base Color")
            .onChange((v) => {
                if (this.shaderRef)
                    this.shaderRef.uniforms.uColorBase.value.set(v);
                this.scene.background = new THREE.Color(v).multiplyScalar(0.5); // half of uColorBase for a more subtle background
            });

        this.gui
            .addColor(this.params, "colorHigh")
            .name("Wave Color")
            .onChange((v) => {
                if (this.shaderRef)
                    this.shaderRef.uniforms.uColorHigh.value.set(v);
            });

        // ── Trail wave controls ───────────────────────────────────────────────
        const trailFolder = this.gui.addFolder("Trail");
        const mw = this.mouseTrail;
        const mu = mw.uniforms;

        trailFolder
            .add(mw.params, "fadeTime", 0.2, 6, 0.1)
            .name("Fade Time")
            .onChange((v) => {
                mu.uFadeTime.value = v;
            });

        trailFolder
            .add(mw.params, "trailSpacing", 0.1, 3, 0.05)
            .name("Trail Spacing");

        trailFolder.open();

        // ── Lighting controls ─────────────────────────────────────────────────
        const lightingFolder = this.gui.addFolder("Lighting");
        const lp = this.lightingParams;

        lightingFolder
            .addColor(lp, "ambientColor")
            .name("Ambient Color")
            .onChange((v) => this.ambientLight.color.set(v));

        lightingFolder
            .add(lp, "ambientIntensity", 0.1, 5, 0.01)
            .name("Ambient Intensity")
            .onChange((v) => {
                this.ambientLight.intensity = v;
            });

        lightingFolder
            .addColor(lp, "directionalColor")
            .name("Key Light Color")
            .onChange((v) => this.directionalLight.color.set(v));

        lightingFolder
            .add(lp, "directionalIntensity", 0.1, 10, 0.01)
            .name("Key Light Intensity")
            .onChange((v) => {
                this.directionalLight.intensity = v;
            });

        lightingFolder
            .addColor(lp, "directional2Color")
            .name("Fill Light Color")
            .onChange((v) => this.directionalLight2.color.set(v));

        lightingFolder
            .add(lp, "directional2Intensity", 0.1, 10, 0.01)
            .name("Fill Light Intensity")
            .onChange((v) => {
                this.directionalLight2.intensity = v;
            });

        lightingFolder
            .add(this.shadowCameraHelper, "visible")
            .name("Show Shadow Camera");
    }

    update(delta) {
        this.mouseTrail.update(delta);
    }
}
src/ThreeJS/Utils/Debug.js
파일 저장

import GUI from "lil-gui";

export default class Debug {
    constructor() {
        this.active = window.location.hash === "#debug";

        if (this.active) {
            this.ui = new GUI();
        }
    }
}
src/ThreeJS/Utils/Sizes.js
파일 저장

import mitt from 'mitt'

export default class Sizes
{
    constructor()
    {
        this.emitter = mitt()

        // Setup
        this.width = window.innerWidth
        this.height = window.innerHeight
        this.pixelRatio = Math.min(window.devicePixelRatio, 2)

        // Resize event
        window.addEventListener('resize', () =>
        {
            this.width = window.innerWidth
            this.height = window.innerHeight
            this.pixelRatio = Math.min(window.devicePixelRatio, 2)

            this.emitter.emit('resize')
        })
    }
}
src/index.html
파일 저장

<!doctype html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>3D Wave Grid</title>
        <link rel="preconnect" href="https://fonts.googleapis.com" />
        <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
        <link
            href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap"
            rel="stylesheet"
        />
        <link rel="stylesheet" href="./style.css" />
    </head>
    <body>
        <div class="overlay">
            <div class="top-nav">
                <div class="nav-logo">
                    <a href="#">Franky.dev</a>
                </div>
                <div class="nav-content">
                    <div class="nav-links">
                        <a href="https://arkon.digital" class="animated-link">
                            <span class="link-content">
                                <span class="link-text">Projects</span>
                                <span class="link-text">Projects</span>
                            </span>
                        </a>
                        <a href="https://arkon.digital" class="animated-link">
                            <span class="link-content">
                                <span class="link-text">About</span>
                                <span class="link-text">About</span>
                            </span>
                        </a>
                        <a href="https://arkon.digital" class="animated-link">
                            <span class="link-content">
                                <span class="link-text">Contact</span>
                                <span class="link-text">Contact</span>
                            </span>
                        </a>
                    </div>
                    <div class="nav-socials">
                        <a href="https://x.com/FrankyHungDev" class="animated-link">
                            <span class="link-content">
                                <span class="link-text">X (Twitter)</span>
                                <span class="link-text">X (Twitter)</span>
                            </span>
                        </a>
                        <a href="https://www.instagram.com/franky_hung/" class="animated-link">
                            <span class="link-content">
                                <span class="link-text">Instagram</span>
                                <span class="link-text">Instagram</span>
                            </span>
                        </a>
                    </div>
                    <div class="nav-time">
                        <p id="local-time"></p>
                    </div>
                </div>
            </div>
            <div class="hero-section">
                <div class="spacer"></div>
                <h1>
                    Franky is a Hong Kong-based creative developer exploring how
                    to blend design and technology to create memorable
                    experiences.
                    <span class="secondary">
                        His work focuses clarity, function, and emotion — always
                        built on inspiration and curiosity.
                    </span>
                </h1>
                <div class="spacer-back"></div>
            </div>
            <div class="bottom-bar">
                <div class="bar-location">
                    <p>Based in Hong Kong</p>
                </div>
                <div class="bar-projects">
                    <a href="https://arkon.digital" class="animated-link">
                        <span class="link-content">
                            <span class="link-text">Check all projects<img class="arrow-icon" src="./arrow.svg" alt="Arrow" /></span>
                            <span class="link-text">Check all projects<img class="arrow-icon" src="./arrow.svg" alt="Arrow" /></span>
                        </span>
                    </a>
                </div>
                <div class="bar-availability">
                    <a href="mailto:franky@arkon.digital" class="animated-link">
                        <span class="link-content">
                            <span class="link-text">Contact Me<img class="arrow-icon" src="./arrow.svg" alt="Arrow" /></span>
                            <span class="link-text">Contact Me<img class="arrow-icon" src="./arrow.svg" alt="Arrow" /></span>
                        </span>
                    </a>
                </div>
            </div>
        </div>
        <canvas class="webgl"></canvas>
        <script type="module" src="./script.js"></script>
    </body>
</html>
src/script.js
파일 저장

import "./style.css";
import Orchestrator from "./ThreeJS/Orchestrator.js";
import gsap from "gsap";

const orchestrator = new Orchestrator(document.querySelector("canvas.webgl"));

function updateTime() {
    const timeElement = document.getElementById("local-time");
    if (timeElement) {
        const now = new Date();
        const options = {
            timeZone: "Asia/Hong_Kong",
            hour: "2-digit",
            minute: "2-digit",
            hour12: true,
        };
        timeElement.textContent =
            now.toLocaleTimeString("en-US", options) + " HKT";
    }
}

updateTime();
setInterval(updateTime, 60000);

// Staggered fade-in animation for text elements
const animatedElements = [
    ".nav-logo a",
    ".nav-links a",
    ".nav-socials a",
    ".nav-time p",
    ".hero-section h1",
    ".bar-location p",
    ".bar-projects a",
    ".bar-availability a",
];

gsap.fromTo(
    animatedElements,
    {
        opacity: 0,
        y: 20,
    },
    {
        duration: 1,
        opacity: 1,
        y: 0,
        stagger: 0.1,
        ease: "power3.out",
        delay: 0.5,
    },
);
src/style.css
파일 저장

:root {
    --text-color: #000000;
    --secondary-text-color: #616161;
}

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

html,
body {
    overflow: hidden;
    font-family: "Inter", sans-serif;
    color: var(--text-color);
    -webkit-font-smoothing: antialiased;
}

.webgl {
    position: fixed;
    top: 0;
    left: 0;
    outline: none;
}

.overlay {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    padding: 2rem;
    pointer-events: none;
    z-index: 1;
}

/* Initially hide elements for gsap animation */
.nav-logo a,
.nav-links a,
.nav-socials a,
.nav-time p,
.hero-section h1,
.bar-location p,
.bar-projects a,
.bar-availability a {
    opacity: 0;
}

.overlay a {
    color: var(--text-color);
    text-decoration: none;
    pointer-events: all;
}

/*
 * Navigation Bar
 */
.top-nav {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    display: flex;
    padding: 1rem;
    gap: 1rem;
    -webkit-backdrop-filter: blur(10px);
    backdrop-filter: blur(
        10px
    ); /* needs to be below the -webkit- prefix for Chrome to work */
}
.top-nav a {
    color: var(--secondary-text-color);
    font-size: 18px;
    font-weight: 400;
}
.top-nav .nav-logo {
    width: 36%;
}
.top-nav .nav-logo a {
    font-size: clamp(3.5rem, 6vw, 5.5rem);
    letter-spacing: -0.04em;
    font-weight: bold;
    color: var(--text-color);
}
.top-nav .nav-content {
    display: flex;
    flex: 1;
    gap: 0.5rem;
}
.top-nav .nav-content .nav-links,
.top-nav .nav-content .nav-socials {
    display: flex;
    gap: 0.5rem;
    flex-direction: column;
    flex: 1;
}
.top-nav .nav-content .nav-time {
    color: var(--secondary-text-color);
    flex: 1;
}

/*
 * Hero Section
 */
.hero-section {
    margin-top: 40vh;
    height: 60vh;
    display: flex;
    justify-content: flex-start;
}
.hero-section .spacer {
    width: 36%;
}
.hero-section .spacer-back {
    width: 10%;
}
.hero-section h1 {
    font-size: clamp(1.5rem, 2.5vw, 2rem);
    flex: 1;
    font-weight: 400;
}
.hero-section h1 .secondary {
    color: var(--secondary-text-color);
}

/*
 * Bottom Bar
 */
.bottom-bar {
    display: flex;
    justify-content: space-between;
    width: 100%;
}
.bottom-bar .bar-location {
    width: 36%;
}
.bottom-bar .bar-projects,
.bottom-bar .bar-availability {
    flex: 1;
}

/*
 * Animated Link
 */
.animated-link {
    display: inline-block;
    overflow: hidden;
    line-height: 1.2;
    height: 1.2em;
}
.link-content {
    display: flex;
    flex-direction: column;
    transform: translateY(-50%);
    transition: all 0.8s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.link-text {
    display: flex;
    align-items: center;
    white-space: nowrap;
    height: 1.2em;
}
.link-text img {
    margin-left: 0.5rem;
    width: 20px;
    height: 20px;
}
.animated-link:hover .link-content {
    transform: translateY(0%);
    color: var(--text-color);
}

/* Responsive Design */
@media (max-width: 768px) {
    .overlay {
        padding: 1rem;
    }

    .top-nav {
        flex-direction: column;
        align-items: center;
        gap: 1rem;
    }
    .top-nav .nav-logo {
        order: -1;
        width: 100%;
        text-align: center;
    }
    .top-nav .nav-logo a {
        font-size: 2rem;
    }
    .top-nav .nav-content {
        width: 100%;
        gap: 0.5rem;
    }

    .hero-section h1 {
        font-size: 1rem;
        align-items: center;
        text-align: center;
    }
    .hero-section .spacer,
    .hero-section .spacer-back {
        display: none;
    }

    .bottom-bar {
        gap: 0.5rem;
        align-items: flex-end;
        font-size: 12px;
    }
}
vite.config.js
파일 저장

import restart from "vite-plugin-restart";

export default {
    root: "src/", // Sources files (typically where index.html is)
    publicDir: "../public/", // Path from "root" to static assets (files that are served as they are)
    server: {
        host: true, // Open to local network and display URL
        open: !(
            "SANDBOX_URL" in process.env || "CODESANDBOX_HOST" in process.env
        ), // Open if it's not a CodeSandbox
    },
    build: {
        outDir: "../dist", // Output in the dist/ folder
        emptyOutDir: true, // Empty the folder first
        sourcemap: true, // Add sourcemap
    },
    plugins: [
        restart({ restart: ["../public/**"] }), // Restart server on static file change
    ],
};
LICENSE실행 안내·자료
파일 저장

MIT License

Copyright (c) 2026 franky-adl

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실행 안내·자료
파일 저장

GSAP standard license snapshot
Source: https://gsap.com/community/standard-license/
Retrieved: 2026-09-22T04:22:49.089Z
Original distribution copyright and version headers remain in the runtime code.

Standard "No Charge" GSAP License

 I. DEFINITIONS

 “GSAP License” means the terms and conditions of this GSAP Software License Agreement.

 "GSAP Products" means any Software made available at gsap.com (https://gsap.com) or any successor sites, including but not limited to the GSAP animation library and related plugins, tools, or extensions.

 "Permitted Uses" means the implementation and/or use of GSAP Products on any website, web application, or digital interface by any person or entity (which may include, for clarity, those of companies that compete with Webflow in other areas of business).

 "Prohibited Uses" means any implementation and/or use of GSAP Products in tools that allow users to build visual animations without code that encourages, induces, or materially assists in creating a solution that competes with Webflow’s visual animation building capabilities.

 "Competitive Products" means any software, tool, or service that enables users to create, edit, or manage animations through a visual interface or builder similar to Webflow (https://webflow.com).

 II. GRANT OF LICENSE

 Subject to the terms and conditions of this GSAP License, Webflow grants you a non-exclusive, worldwide license to use, reproduce, display, and implement GSAP Products solely for Permitted Uses.

 III. RESTRICTIONS

 You may not:

 Use any GSAP Products for any Prohibited Uses without prior written consent;

 Reverse engineer any GSAP Products for the purpose of creating Competitive Products;

 Remove or alter any proprietary notices or branding from GSAP Products.

 IV. OWNERSHIP AND INTELLECTUAL PROPERTY

 All intellectual property rights in GSAP Products, including but not limited to copyright, patents, trademarks, and trade secrets, remain the exclusive property of Webflow. This GSAP License does not transfer any ownership rights in GSAP Products to you.

 V. TERMINATION

 Webflow may terminate this GSAP License and revoke your access in its discretion if you fail to comply with any of these terms and conditions. Upon termination, you must cease all use of GSAP Products and destroy all copies in your possession.

 VI. MISCELLANEOUS PROVISIONS

 General: This GSAP License is incorporated into and subject to Webflow’s Terms of Service available here (https://webflow.com/legal/terms) ("Terms of Service"). In the event of any conflict or inconsistency between this GSAP License and the Terms of Service, the terms of this GSAP License shall govern in relation to your use of any GSAP Products.

 Amendments: Webflow reserves the right to update or modify this GSAP License at any time by posting the revised terms on this website, provided that any such updates or modifications shall not result in any material degradation to the security, integrity, or functionality of any GSAP Products. You understand and agree that your continued use of any GSAP Products after such revisions to this GSAP License constitutes your acceptance of this GSAP License as revised. If you do not accept the revised GSAP License, you are prohibited from using versions of the GSAP Products released after the effective date of the revised GSAP License (as well as any updates made to previous versions). Notwithstanding, you may continue using previous versions of GSAP Products under the applicable terms licensed to you prior to the effective date of the revised GSAP License (for clarity, excluding any updates made thereto).

 No Waiver: Failure of Webflow to enforce any provision of this GSAP License shall not constitute a waiver of future enforcement of that or any other provision.

 FAQ

 Is it acceptable for AI tools like ChatGPT, Cursor, Lovable, Webstudio, etc. to generate GSAP code?
 Absolutely! AI-generated code is not a "Prohibited Use".

 What if a WordPress plugin or theme or other niche tool allows users to create GSAP-driven effects through a visual interface? Is that prohibited?
 We want to encourage developers to build on top of GSAP, including visual tools that don't directly compete with Webflow's rich animation-building capabilities. If you are not sure if your product might be considered a "Prohibited Use", feel free to contact us (https://gsap.com/contact) so we can talk through it!

 Can I really use GSAP in commercial projects without paying anything?
 Yes, really! Commercial usage is covered under the standard license. All of GSAP including the plugins that were formerly "members-only" like SplitText (https://gsap.com/docs/v3/Plugins/SplitText/) and MorphSVG (https://gsap.com/docs/v3/Plugins/MorphSVGPlugin) can be used in commercial projects at no charge. Enjoy! 💚

 Effective date: April 30, 2025

 Last modified date: May 30, 2025

 Copyright (©) 2025, Webflow


three@0.184.0 — LICENSE
The MIT License

Copyright © 2010-2026 three.js authors

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

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

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


stats.js@0.17.0 — LICENSE
The MIT License

Copyright (c) 2009-2016 stats.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.


lil-gui@0.21.0 — LICENSE.md
MIT License

Copyright (c) 2019 George Michael Brower

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.

mitt@3.0.1 — LICENSE
MIT License

Copyright (c) 2021 Jason Miller

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.