Codrops 원본
Building an Interactive Wave Propagation Cube Grid with Three.js
배경 · MIT
갤러리 안에서는 화면을 벗어나거나 움직임을 멈추면 예제도 닫힙니다. 다시 재생할 때는 처음부터 시작해요.
SOURCE FILES
원본 코드 읽기
수집한 원본 소스와 실행 안내를 함께 제공합니다.
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.
BEHIND THE EXAMPLE
이 장면을 만드는 원리
시간이 지난 포인터 자취의 파동 합성
화면 크기·입력·동시 작업을 고정하고 실제 렌더링 실패와 비용을 관찰합니다.
- 이 예제에서는
- 정점 셰이더는 자취의 거리와 나이로 파면·감쇠를 계산합니다. 가중 합을 정규화하고 최대 높이를 제한하며 윗면 정점만 움직입니다.
