here STAGE: live events inside shared 3D venues, in the browser

Hi all! I’ve been building here STAGE (https://herestage.com), a platform where creators host live shows inside 3D venues and fans join as avatars to watch together, chat and emote in real time. No download, it runs in the browser on desktop and mobile.

Creators stream from YouTube or Twitch onto the stage screen, and everyone in the room moves around, sits, dances and chats while it plays. There are live shows plus 24/7 stages you can drop into anytime.

Stack: Three.js, Next.js, Supabase (realtime for avatar positions, presence and chat), YouTube/Twitch embeds for the stage screens. Want to eventually host streams and videos on our own of course.

A few things I learned along the way that might help someone:

Avatars: parse once, clone many. Every avatar used to load and parse its own GLB. Now the model is parsed once and each avatar is a SkeletonUtils.clone(), sharing geometry and animation clips. Memory stays flat as the room fills up, which matters with NPC crowds of 30 to 60 avatars on some stages that we use for testing.

Mixamo animation files carry a hidden mesh. Each exported animation GLB embeds a full copy of the character mesh you never use. Stripping them down to bones and clips only cut about 2.2MB from the animation set alone. Combined with meshopt compression on the base mesh and props (EXT_meshopt_compression, so every GLTFLoader needs setMeshoptDecoder) and optimizing SVGs, the venue got about 3.5MB lighter.

Shader warm-up helped desktop and broke mobile. Holding the loader until renderer.compileAsync() finished removed the first-frame stutter on desktop. On phones the same warm-up spiked GPU memory hard enough to trigger webglcontextlost, which kicked off a scene rebuild loop. It’s desktop-only now. Lesson: profile mobile separately, never assume.

A fixed render resolution. setPixelRatio(1) with alpha: false was a much bigger win on high-DPI phones than any micro-optimization.

Name labels via transform, not left/top. Moving the DOM labels above avatars with CSS transforms instead of position properties stopped layout thrashing every frame.

YouTube on iOS Chrome was the hardest bug. Chrome on iOS is a WKWebView app, and hand-building the iframe then binding YT.Player to it raced the iframe’s load. On a slow load the player bound to nothing and onReady never fired. The fix: pass a placeholder div and let the YouTube API build the iframe itself.

I’d love feedback, especially on performance on your devices and anything that feels off when walking around. Happy to go deeper on any of the above.