This experiment is intentionally “zero-build”: it runs as plain ES modules and loads Three.js from a CDN importmap.
flowchart TD
indexHtml[index.html] --> mainJs[main.js]
mainJs --> appInit[initFlyingStuff]
appInit --> threeScene[THREE_Scene_Camera_Renderer]
appInit --> flyersFactory[createFlyers]
flyersFactory --> flyersSim[Pool_Sim_Update]
flyersSim --> renderables[Sprites_or_DepthMeshes]
appInit --> rafLoop[RAF_loop_or_Snapshots]
rafLoop --> flyersSim
rafLoop --> rendererRender[renderer.render]
main.js
initFlyingStuff({ container }).app.js
THREE.Scene (background + fog)THREE.PerspectiveCameraTHREE.WebGLRendererflyers by calling createFlyers(scene, camera, config).flyers setters (count/speed/size + theme changes).flyers.update(dtMs) → renderer.render(scene, camera)prefers-reduced-motion behavior: disables RAF and uses snapshot renders for interaction feedback.flyers.js
setEmojiTheme (immediate)setEmojiThemeForNewSpawns (spawn-only)transitionEmojiTheme (crossfade via “ghost” renderables)initFlyingStuff({ container })Creates and starts the experiment. Returns an object with:
dispose(): stops animation, unsubscribes listeners, disposes GPU resources, and removes the canvas.showFallback(): reveals the fallback UI (used on WebGL init failures).createFlyers(scene, camera, opts)Creates a THREE.Group under scene and returns an object used by app.js:
update(deltaMs)trySplatAtNdc(ndcX, ndcY)setCountTarget(count, { rampSeconds })setSpeedTarget(speed, { rampSeconds })setSizeTarget(size, { rampSeconds })setEmojiTheme({ emojiList, emojiStyleByEmoji, effects })setEmojiThemeForNewSpawns({ ... })transitionEmojiTheme({ ... }, { durationMs })dispose()Important invariants:
update() is hot path (called every frame). Avoid adding allocations/logs here.