Skip to content

Instantly share code, notes, and snippets.

@luthviar
Created January 12, 2026 08:27
Show Gist options
  • Select an option

  • Save luthviar/6406b2a2005152a54f61c0b212930c5c to your computer and use it in GitHub Desktop.

Select an option

Save luthviar/6406b2a2005152a54f61c0b212930c5c to your computer and use it in GitHub Desktop.
cyberpunk.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Cyberpunk Particle System</title>
<!-- Fonts -->
<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=Orbitron:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--neon-cyan: #00FFFF;
--neon-blue: #0088FF;
--neon-pink: #FF00FF;
--neon-green: #00FF88;
--neon-yellow: #FFFF00;
--bg-color: #050505;
}
body {
margin: 0;
overflow: hidden;
background-color: var(--bg-color);
font-family: 'Orbitron', sans-serif;
color: var(--neon-cyan);
user-select: none;
}
/* Vignette & Scanlines */
#vignette {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
background: radial-gradient(circle, transparent 40%, black 100%);
z-index: 10;
}
#scanlines {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.25) 50%), linear-gradient(90deg, rgba(255, 0, 0, 0.06), rgba(0, 255, 0, 0.02), rgba(0, 0, 255, 0.06));
background-size: 100% 4px, 6px 100%;
opacity: 0.6;
z-index: 11;
}
/* HUD */
.hud-panel {
position: fixed;
z-index: 20;
padding: 10px;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 2px;
text-shadow: 0 0 5px var(--neon-cyan);
}
#hud-tl { top: 20px; left: 20px; text-align: left; }
#hud-tr { top: 20px; right: 20px; text-align: right; }
#hud-bl { bottom: 20px; left: 20px; text-align: left; }
#hud-br { bottom: 20px; right: 20px; text-align: right; }
.hud-label {
display: block;
font-size: 0.8em;
opacity: 0.7;
margin-bottom: 5px;
}
.hud-value {
font-size: 1.2em;
font-weight: bold;
}
/* Video Input (Hidden) */
#webcam {
display: none;
transform: scaleX(-1);
}
canvas {
display: block;
}
/* Loading Overlay */
#loading {
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
background: #000;
display: flex;
justify-content: center;
align-items: center;
color: var(--neon-cyan);
font-size: 24px;
z-index: 100;
flex-direction: column;
}
.loader-bar {
width: 200px;
height: 4px;
background: #333;
margin-top: 20px;
position: relative;
}
.loader-progress {
position: absolute;
top: 0; left: 0; height: 100%;
background: var(--neon-cyan);
width: 0%;
transition: width 0.3s;
box-shadow: 0 0 10px var(--neon-cyan);
}
</style>
<!-- Import Maps -->
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/",
"@mediapipe/tasks-vision": "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.9/+esm"
}
}
</script>
</head>
<body>
<!-- Visual Overlays -->
<div id="vignette"></div>
<div id="scanlines"></div>
<!-- HUD -->
<div id="hud-tl" class="hud-panel">
<span class="hud-label">FPS</span>
<span id="fps-counter" class="hud-value">00</span>
</div>
<div id="hud-tr" class="hud-panel">
<span class="hud-label">Particles</span>
<span id="particle-counter" class="hud-value">12000</span>
</div>
<div id="hud-bl" class="hud-panel">
<span class="hud-label">L-Hand Status</span>
<span id="l-hand-status" class="hud-value">SEARCHING...</span>
</div>
<div id="hud-br" class="hud-panel">
<span class="hud-label">R-Hand Status</span>
<span id="r-hand-status" class="hud-value">SEARCHING...</span>
</div>
<!-- Loading Screen -->
<div id="loading">
<div>INITIALIZING SYSTEM...</div>
<div class="loader-bar"><div class="loader-progress" id="loader-progress"></div></div>
</div>
<!-- Video & Canvas -->
<video id="webcam" playsinline autoplay></video>
<!-- Three.js Canvas will be appended here automatically -->
<script type="module">
import * as THREE from 'three';
import { FilesetResolver, HandLandmarker } from '@mediapipe/tasks-vision';
// --- Configuration ---
const CONFIG = {
particleCount: 12000,
particleSize: 2.4,
returnSpeed: 0.16,
colors: {
blue: 0x00FFFF,
yellow: 0xFFFF00,
pink: 0xFF00FF,
green: 0x00FF88,
orange: 0xFFA500
}
};
// --- Audio System ---
class AudioSynth {
constructor() {
this.ctx = null;
this.master = null;
}
init() {
if (this.ctx) return;
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
this.master = this.ctx.createGain();
this.master.connect(this.ctx.destination);
this.master.gain.value = 0.4;
// Startup sound
this.playSweep(200, 800, 0.5, 'sine');
}
playTone(freq, type = 'sine', duration = 0.1) {
if (!this.ctx) return;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, this.ctx.currentTime);
// Pitch slide for "tech" feel
osc.frequency.exponentialRampToValueAtTime(freq * 0.8, this.ctx.currentTime + duration);
gain.gain.setValueAtTime(0.3, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + duration);
osc.connect(gain);
gain.connect(this.master);
osc.start();
osc.stop(this.ctx.currentTime + duration);
}
playSoundForState(state) {
if (!this.ctx) return;
switch(state) {
case 'HELLO': this.playTone(440, 'triangle', 0.2); break;
case 'GEMINI': this.playTone(554, 'square', 0.2); break;
case 'USEFUL': this.playTone(659, 'sawtooth', 0.25); break;
case 'BYE': this.playTone(880, 'sine', 0.15); break;
case 'CATCH': this.playGlitch(); break;
case 'NEBULA': this.playSweep(100, 1200, 1.0, 'sawtooth'); break;
}
}
playGlitch() {
if (!this.ctx) return;
const bufferSize = this.ctx.sampleRate * 0.2; // 200ms
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
const noise = this.ctx.createBufferSource();
noise.buffer = buffer;
const gain = this.ctx.createGain();
gain.gain.setValueAtTime(0.5, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + 0.2);
// Filter for "digital" sound
const filter = this.ctx.createBiquadFilter();
filter.type = 'highpass';
filter.frequency.value = 1000;
noise.connect(filter);
filter.connect(gain);
gain.connect(this.master);
noise.start();
}
playSweep(startFreq = 200, endFreq = 2000, duration = 1.0, type='sine') {
if (!this.ctx) return;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(startFreq, this.ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(endFreq, this.ctx.currentTime + duration);
gain.gain.setValueAtTime(0.2, this.ctx.currentTime);
gain.gain.linearRampToValueAtTime(0, this.ctx.currentTime + duration);
osc.connect(gain);
gain.connect(this.master);
osc.start();
osc.stop(this.ctx.currentTime + duration);
}
}
// --- Text & Coordinate Generator ---
class TextGenerator {
constructor(width, height) {
this.width = width;
this.height = height;
this.canvas = document.createElement('canvas');
this.canvas.width = width;
this.canvas.height = height;
this.ctx = this.canvas.getContext('2d');
}
generate(text) {
this.ctx.clearRect(0, 0, this.width, this.height);
this.ctx.font = 'bold 150px Orbitron'; // Scaled for resolution
this.ctx.fillStyle = 'white';
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
this.ctx.fillText(text, this.width / 2, this.height / 2);
const imageData = this.ctx.getImageData(0, 0, this.width, this.height);
const positions = [];
// Scan pixels - optimize step based on particle neeeds
const step = 4;
for (let y = 0; y < this.height; y += step) {
for (let x = 0; x < this.width; x += step) {
const index = (y * this.width + x) * 4;
if (imageData.data[index] > 128) {
// Map 2D to 3D. Center is (0,0)
const posX = (x - this.width / 2) * 0.1;
const posY = -(y - this.height / 2) * 0.1; // Invert Y
positions.push(posX, posY, 0);
}
}
}
return positions;
}
}
// --- Particle System ---
class ParticleSystem {
constructor(scene, camera) {
this.scene = scene;
this.camera = camera;
this.textGen = new TextGenerator(1024, 512);
// State
this.currentShape = 'TEXT'; // TEXT, SPHERE, NEBULA
this.targetPositions = new Float32Array(CONFIG.particleCount * 3);
// Initialize Geometry
this.geometry = new THREE.BufferGeometry();
const positions = new Float32Array(CONFIG.particleCount * 3);
const colors = new Float32Array(CONFIG.particleCount * 3);
const sizes = new Float32Array(CONFIG.particleCount);
const color = new THREE.Color(CONFIG.colors.blue);
for(let i=0; i<CONFIG.particleCount; i++) {
// Random start positions
positions[i*3] = (Math.random() - 0.5) * 300;
positions[i*3+1] = (Math.random() - 0.5) * 300;
positions[i*3+2] = (Math.random() - 0.5) * 100;
colors[i*3] = color.r;
colors[i*3+1] = color.g;
colors[i*3+2] = color.b;
sizes[i] = CONFIG.particleSize * (0.5 + Math.random() * 0.5);
}
this.geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
this.geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
this.geometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1));
// Material - Using shader for better control/glow
const sprite = new THREE.TextureLoader().load('https://threejs.org/examples/textures/sprites/disc.png');
this.material = new THREE.PointsMaterial({
size: CONFIG.particleSize,
vertexColors: true,
map: sprite,
blending: THREE.AdditiveBlending,
depthWrite: false,
transparent: true,
opacity: 0.9,
sizeAttenuation: true
});
this.mesh = new THREE.Points(this.geometry, this.material);
this.scene.add(this.mesh);
// Track finger state to prevent spamming
this.lastFingers = -1;
this.lastTrigger = 0;
// Initial Text
this.setTargetText("Hello", CONFIG.colors.blue);
}
update(dt, handData) {
const positions = this.geometry.attributes.position.array;
const pColors = this.geometry.attributes.color.array;
// 1. Determine State
let mode = 'DEFAULT';
let repulsionPoint = null;
let attractionPoint = null;
// Left Hand Logic (Command)
if (handData && handData.left) {
this.handleLeftHand(handData.left);
}
// Right Hand Logic (Interactor)
if (handData && handData.right) {
if (handData.right.isOpen) {
mode = 'NEBULA'; // Scatter
} else {
// Pointing/Fist: Repulsion
repulsionPoint = handData.right.indexTip;
}
}
// Combo Logic
if (handData && handData.left && handData.left.isOpen &&
handData && handData.right && handData.right.isOpen) {
mode = 'BASKETBALL';
attractionPoint = handData.left.position;
}
// 2. Physics & Position Updates
const time = performance.now() * 0.001;
for(let i=0; i<CONFIG.particleCount; i++) {
const idx = i*3;
let targetX, targetY, targetZ;
// Mode: Nebula -> Scatter + Sine Wave Ripple
if (mode === 'NEBULA') {
// Scatter to fill screen
targetX = positions[idx] + Math.sin(time + i) * 0.5;
targetY = positions[idx+1] + Math.cos(time + i) * 0.5;
targetZ = positions[idx+2] + (Math.random()-0.5) * 2;
// Ripple logic: if hand moves through?
if (handData && handData.right) {
const dx = positions[idx] - handData.right.position.x;
const dy = positions[idx+1] - handData.right.position.y;
const dist = Math.sqrt(dx*dx + dy*dy);
if (dist < 50) {
const ripple = Math.sin(dist * 0.5 - time * 10) * 5;
targetZ += ripple;
}
}
} else if (mode === 'BASKETBALL' && attractionPoint) {
// Golden Spiral Sphere (Fibonacci)
const phi = Math.acos( -1 + ( 2 * i ) / CONFIG.particleCount );
const theta = Math.sqrt( CONFIG.particleCount * Math.PI ) * phi;
const r = 25; // Radius
const sx = r * Math.sin(phi) * Math.cos(theta + time * 2);
const sy = r * Math.sin(phi) * Math.sin(theta + time * 2);
const sz = r * Math.cos(phi);
// Bouncing visualization (Y-axis sine)
const bounce = Math.abs(Math.sin(time * 5 + i * 0.01)) * 5;
targetX = attractionPoint.x + sx;
targetY = attractionPoint.y + sy + bounce;
targetZ = sz;
// Color: Orange
pColors[idx] = 1.0;
pColors[idx+1] = 0.65; // Orange-ish
pColors[idx+2] = 0.0;
// Black lines? (Modulo logic)
if (Math.abs(sx) < 1 || Math.abs(sy) < 1) {
pColors[idx] = 0; pColors[idx+1]=0; pColors[idx+2]=0;
}
} else {
// DEFAULT: Go to Target (Text)
targetX = this.targetPositions[idx];
targetY = this.targetPositions[idx+1];
targetZ = this.targetPositions[idx+2];
}
// Apply Forces
// Repulsion (Right Hand Point)
if (repulsionPoint && mode === 'DEFAULT') {
const dx = positions[idx] - repulsionPoint.x;
const dy = positions[idx+1] - repulsionPoint.y;
const distSq = dx*dx + dy*dy; // Planar
const radius = 30; // Interaction radius
if (distSq < radius * radius) {
const dist = Math.sqrt(distSq);
const force = (radius - dist) / radius; // 0 to 1
// Strong snappy force
const angle = Math.atan2(dy, dx);
targetX += Math.cos(angle) * force * 50;
targetY += Math.sin(angle) * force * 50;
}
}
// Integration (Lerp)
const lerp = CONFIG.returnSpeed;
positions[idx] += (targetX - positions[idx]) * lerp;
positions[idx+1] += (targetY - positions[idx+1]) * lerp;
positions[idx+2] += (targetZ - positions[idx+2]) * lerp;
}
this.geometry.attributes.position.needsUpdate = true;
this.geometry.attributes.color.needsUpdate = true;
}
handleLeftHand(hand) {
// Debounce / State Machine to prevent flickering
const now = performance.now();
if (Math.abs(now - this.lastTrigger) < 500) return; // 500ms debounce
// Check if gesture changed
if (hand.fingers !== this.lastFingers) {
this.lastFingers = hand.fingers;
this.lastTrigger = now;
switch(hand.fingers) {
case 1:
this.setTargetText("Hello", CONFIG.colors.blue);
window.app.audio.playSoundForState('HELLO');
break;
case 2:
this.setTargetText("Gemini3", CONFIG.colors.yellow);
window.app.audio.playSoundForState('GEMINI');
break;
case 3:
this.setTargetText("非常好用", CONFIG.colors.pink);
window.app.audio.playSoundForState('USEFUL');
break;
case 4:
this.setTargetText("再见", CONFIG.colors.green);
window.app.audio.playSoundForState('BYE');
break;
case 5:
window.app.audio.playSoundForState('CATCH');
break;
}
}
}
setTargetText(text, colorHex) {
const coords = this.textGen.generate(text);
const color = new THREE.Color(colorHex);
const pColors = this.geometry.attributes.color.array;
// Update targets
for(let i=0; i<CONFIG.particleCount; i++) {
const idx = i*3;
// If we have coords, use them. Else random or center.
if (coords && (i*3) < coords.length) {
this.targetPositions[idx] = coords[i*3];
this.targetPositions[idx+1] = coords[i*3+1];
this.targetPositions[idx+2] = coords[i*3+2];
} else {
// Excess particles: hide or float around?
this.targetPositions[idx] = (Math.random() - 0.5) * 200;
this.targetPositions[idx+1] = (Math.random() - 0.5) * 100;
this.targetPositions[idx+2] = (Math.random() - 2) * 50; // Push back
}
// Colors
pColors[idx] = color.r;
pColors[idx+1] = color.g;
pColors[idx+2] = color.b;
}
this.geometry.attributes.color.needsUpdate = true;
}
}
// --- Hand Tracking ---
class HandTracker {
constructor() {
this.video = document.getElementById('webcam');
this.landmarker = null;
this.leftHand = null; // { fingers: 0, isOpen: false, position: {x,y,z} }
this.rightHand = null; // { indexTip: {x,y,z}, isOpen: false, position: {x,y,z} }
}
async init() {
const vision = await FilesetResolver.forVisionTasks(
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.9/wasm"
);
this.landmarker = await HandLandmarker.createFromOptions(vision, {
baseOptions: {
modelAssetPath: `https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task`,
delegate: "GPU"
},
runningMode: "VIDEO",
numHands: 2
});
await this.setupCamera();
}
async setupCamera() {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
width: 1280, height: 720,
facingMode: "user"
}
});
this.video.srcObject = stream;
return new Promise(resolve => {
this.video.onloadedmetadata = () => {
this.video.play();
resolve();
};
});
}
detect() {
if (!this.landmarker || this.video.currentTime <= 0) return null;
const results = this.landmarker.detectForVideo(this.video, performance.now());
this.leftHand = null;
this.rightHand = null;
if (results.landmarks) {
for (const [index, landmarks] of results.landmarks.entries()) {
const handedness = results.handedness[index][0].categoryName;
// Note: MediaPipe "Left" is physically the person's right hand in mirror mode.
// But we want "Left Hand" to be the one on the LEFT side of the screen?
// Actually, standard is: User's Left Hand is "Left" category in MP.
// Let's use CategoryName directly.
// Left Hand (Command)
// Right Hand (Interactor)
const fingers = this.countFingers(landmarks, handedness);
const palmPosition = landmarks[9]; // Middle finger MCP as palm center approx
const handData = {
landmarks: landmarks,
fingers: fingers,
isOpen: fingers === 5,
position: this.toWorldCoords(palmPosition)
};
if (handedness === "Left") {
this.leftHand = handData;
} else {
this.rightHand = handData;
this.rightHand.indexTip = this.toWorldCoords(landmarks[8]);
// Check for gesture states here if needed, or in main loop
}
}
}
// Update HUD
const lStatus = document.getElementById('l-hand-status');
const rStatus = document.getElementById('r-hand-status');
if (lStatus) {
lStatus.innerText = this.leftHand ?
`ACTIVE | FINGERS: ${this.leftHand.fingers}` : "SEARCHING...";
lStatus.style.color = this.leftHand ? "var(--neon-green)" : "var(--neon-cyan)";
}
if (rStatus) {
rStatus.innerText = this.rightHand ?
`ACTIVE | ${this.rightHand.isOpen ? 'NEBULA' : 'POINT'}` : "SEARCHING...";
rStatus.style.color = this.rightHand ? "var(--neon-blue)" : "var(--neon-cyan)";
}
return { left: this.leftHand, right: this.rightHand };
}
countFingers(landmarks, handedness) {
let count = 0;
// Thumb: 4, Index: 8, Middle: 12, Ring: 16, Pinky: 20
// Thumb is tricky. Compare x position of tip (4) vs IP (3)
// For Left hand, thumb tip should be to the Right of IP (larger X) ? No, depends on orientation.
// Simpler check: Distance from Pinky MCP (17) to Thumb Tip (4)
// Better Thumb: Compare X of tip(4) to X of MCP(2).
// Left Hand: Tip.x > MCP.x (if palm facing camera)
// Right Hand: Tip.x < MCP.x
if (handedness === "Left") {
if (landmarks[4].x > landmarks[3].x) count++;
} else {
if (landmarks[4].x < landmarks[3].x) count++;
}
// Fingers: Tip.y < PIP.y (Note: Y is inverted in 3D, but 0 at top in 2D MP)
// In MP 2D: 0 is top. So tip.y < pip.y means extended up.
if (landmarks[8].y < landmarks[6].y) count++;
if (landmarks[12].y < landmarks[10].y) count++;
if (landmarks[16].y < landmarks[14].y) count++;
if (landmarks[20].y < landmarks[18].y) count++;
return count;
}
toWorldCoords(landmark) {
// MP: x [0,1], y [0,1]. (0,0) is Top-Left.
// 3D: (0,0) is Center. Y Up.
// Config
const fov = 75;
const cameraZ = 100;
const aspect = window.innerWidth / window.innerHeight;
// Calculate visible height at Z=0
const vHeight = 2 * Math.tan((fov * Math.PI / 180) / 2) * cameraZ;
const vWidth = vHeight * aspect;
const x = (landmark.x - 0.5) * vWidth; // X is NOT inverted because of mirror
const y = -(landmark.y - 0.5) * vHeight; // Y Inverted
return { x: -x, y, z: 0 }; // Mirror X Logic: MP x=0 is left. Camera x=-width/2 is left.
// Wait. We are mirroring video with CSS scaleX(-1).
// MP coords: 0 is left edge of VIDEO (which is mirrored).
// Visual left is MP Right (x=1)? No.
// If CSS mirrors: User raises Left hand.
// Camera sees it on Right side of frame.
// MP says x = 0.8 (Right side).
// Screen shows it on Left side (CSS mirror).
// We want 3D object to be at Left side.
// So if x = 0.8, we want it at negative X.
// (0.8 - 0.5) * width = +0.3 * width. We want negative.
// So: -(x - 0.5)
}
}
// --- Main App ---
class App {
constructor() {
this.initThree();
this.particles = new ParticleSystem(this.scene, this.camera);
this.tracker = new HandTracker();
this.audio = new AudioSynth();
this.clock = new THREE.Clock();
// UI
this.fpsElem = document.getElementById('fps-counter');
this.loader = document.getElementById('loading');
this.loaderBar = document.getElementById('loader-progress');
// Start
this.init();
}
initThree() {
this.scene = new THREE.Scene();
// Fog for depth
this.scene.fog = new THREE.FogExp2(0x000000, 0.002);
this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
this.camera.position.z = 100;
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
document.body.appendChild(this.renderer.domElement);
window.addEventListener('resize', () => this.onResize());
}
async init() {
this.updateLoading(20);
// Audio init on first click
window.addEventListener('click', () => this.audio.init(), { once: true });
this.updateLoading(40);
await this.tracker.init();
this.updateLoading(90);
// Hide loader
this.loader.style.opacity = 0;
setTimeout(() => this.loader.remove(), 500);
this.animate();
}
updateLoading(percent) {
this.loaderBar.style.width = percent + '%';
}
onResize() {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
}
animate() {
requestAnimationFrame(() => this.animate());
const dt = this.clock.getDelta();
const handData = this.tracker.detect();
// Update Logic
this.particles.update(dt, handData);
// Update HUD
this.fpsElem.textContent = Math.round(1 / dt);
this.renderer.render(this.scene, this.camera);
}
}
// Boot
new App();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment