Exploring 3D Audio-Visual Integration in Three.js: A Journey of Music and Interactive Graphics

Aug 22, 2026 751 views

Sixty Frames for the Record: A Three.js Game, Seven Fly-Throughs, and a Wall of CRTs

What building a Three.js lab around my own music taught me about the renderer.

Creating music under two aliases, LXSTNGHT channels a darker vibe, while F/MEMORY pushes hardwave and chill. My dual interests converge in a web-focused project that integrates these musical styles through visual storytelling. It’s not just about mixing beats with graphics; the necessity of unifying my creations led me to develop a lab featuring a collage of interactive elements in Three.js and React Three Fiber, where music finds its visual counterpart.

This setup imposed certain creative constraints that ultimately shaped my workflow. While a demo can showcase technological prowess, a release must evoke a specific atmosphere consistently for the duration of a track—all while maintaining a fluid 60 frames per second across various devices. The central aim has always been delivering stunning visuals within a browser environment. What I gained was a critical understanding of resource allocation: how effects impact performance, how each frame constructs the experience, and why performance pitfalls sometimes emerged out of nowhere.

This piece provides a walk-through of three projects shaped by this methodology: FRONTIER, an interactive twist on Space Invaders that includes my music; the LXSTNGHT series, consisting of seven immersive fly-through environments; and /fm, an installation featuring malfunctioning CRT monitors pairing with the F/MEMORY album. While I modeled all geometry in Blender—abiding by that creative principle—here, I’m diving deeper into the often invisible complexities of the rendering pipeline, particularly what happens beyond the point where useGLTF resolves.

FRONTIER: the game

Play it → lab.filipzrnzevic.com/frontier

FRONTIER reimagines Space Invaders, staged like a broadcasting event. It employs a voxel fleet and atmospheric fog for immersive depth, backed by a single Draco GLB that renders a dynamic configuration of Invaders using InstancedMesh. The mechanics are simple yet rich: six types of invaders march across the screen in a fluid two-frame rhythm, while bosses disintegrate piece by piece as players bombard them.

The floor is a shader, because every texture failed

Early attempts at designing the deck encountered several issues—varied textures created unexpected visual feedback. Four complete iterations failed: a textured surface, a reflecting plane, a black mirror—all stumbled for different reasons. It became evident that eliminating all surface textures was the key; thus, I created a hexagonal lattice using one analytic ShaderMaterial, removing the risk of inaccurate color rendering entirely. This approach enabled the ground to manipulate fog interactively, and respond accurately to shadow maps.

What emerged from this process was a lesson that extended beyond just my game: when creating custom shaders, lighting setups do not automatically apply unless you manually incorporate them. The patrol's searchlight failed to illuminate the deck for days until it transformed into a unified material.

Menus became things in the lane

In-between combat waves, players select upgrades in a more integrated manner. Originally, a modal overlay interrupted gameplay, pausing the action. The revised design features hex gates that block players while the conflict rages on, allowing them to choose upgrades organically without breaking immersion. Entering a gate implicates a strategic choice—fly through one and sacrifice access to another.

One of my favorite touches: RingGeometry(r, r + 0.34, 6) cleverly represents a hex ring, derived purely from the game’s grid scale. No elaborate modeling required—just pure geometric manipulation.

The record is the reason the game exists

The game essentially serves as a jukebox for F/MEMORY's latest album, with the new track being the centerpiece. For anyone aiming to build a music-driven game, remember: the soundtrack should be the core experience, while sound effects act merely as punctuation. As players engage in FRONTIER, all sound effects are routed through a master bus that ducks during music playback, creating a seamless blend of gameplay and audio experience.

What the fight costs, measured

Analyzing performance on an M1 Max GPU revealed significant insights. During gameplay, with a resolution of 5.2 megapixels, the entire frame consumed only 2 ms of GPU time at the 95th percentile. This performance benchmark included swarming entities, shadow computations, HDR bloom effects, and overall composition. What’s important to note is that the game was never bogged down by pixel fill. Here are three takeaways worth highlighting:

Focus on pixel budgets, not device-pixel-ratio. Setting dpr={2} for a large window inflated the game's draw buffer to 9.7 megapixels unnecessarily. Regardless of whether the game is launched on a 5K monitor or a laptop screen, the pixel budget remains constant (5.2 MP) while adapting the DPR accordingly. This results in consistent visual output, reducing fill authentication by half.

Avoid using mix-blend-mode over WebGL canvases. Incorporating a full-screen cover with mix-blend-mode: color results in significant overhead, with draw passes operating innocently while the entire process suffers performance issues of 30–45 ms when composited. A refined compositor effect can produce the same visual results more efficiently, constricting resource use.

Control light intensity, not visibility. In Three.js, scene light counts are ingrained into each material’s shader as a compile-time constant. FRONTIER managed light sources by keeping them hidden while still counted in programs, averting unwanted recompilation during gameplay. This strategy diminished shader re-compilation from potentially hundreds to a mere statistical change of zero.

        <group> {/* always visible */}
        <group ref={meshes} visible={false}> {/* the hull toggles */}
            <instancedMesh ... />
        </group>
        <pointLight intensity={0} ... /> {/* always counted */}
        </group>
    

A light assigned an intensity of zero still accounts for its slot in the count, keeping shader programs intact. This simple structural adjustment eliminated mid-combat shader compilations altogether.

LXSTNGHT: one engine, seven worlds

The shelf → lab.filipzrnzevic.com/lxstnght

The LXSTNGHT series comprises seven visually distinct fly-throughs—each representing a unique environment designed to enhance the album. These spaces range from abandoned vessels to biomechanical hallways. Users can navigate fluidly as the music score accompanies the visuals.

It’s essential to clarify: these are experimental works, each at different levels of polish and optimization. Some have undergone full performance assessments, while others are still developing. This dynamic is why the shelf exists—to enable continuous enhancement, cinematic depth, and improved browser performance.

The series remains cohesive due to a guiding principle: the runtime functions based on material name. For instance, HullMetal corresponds to weathered metal attributes, while Strip and HoleGlowMat are emissive materials. This modularity allows for easy swaps between GLBs, relying on a pre-established framework of camera rigs, HUDs, audio maps, and so on. There's even a prototype to verify that this structured methodology genuinely streamlined development.

This section will continue to detail the technical challenges addressed during the creation of the series, emphasizing the lessons learned while navigating the complexities of Three.js.

Draw calls: 1,421 → 25, then un-learning half of it (longeron)

The segment longeron, a corridor extracted from a Blender scene, initially presented 1,421 draw calls for 568k triangles. A single draw call per every object resulted in performance bottlenecks through excessive draw calls.

The remedy was straightforward: retain shared geometries by baking matrices, condensing groups by material type, and merging geometries. This reduced it to just 25 draw calls, operating visually without any deterioration.

However, further evaluation revealed a flaw: merging all material types disabled frustum culling. Consequently, the entire corridor would submit every frame rather than only what could be seen. Despite incorrectly assuming the view ahead represented the entire scene, I failed to account for what lurked behind. Retuning the merge process to segment the corridor every three meters effectively restored frustum culling, cutting down submitted triangles by up to 37%, depending on your position in the corridor.

The process of merging while retaining object identity proved to be crucial for animations, allowing for per-panel control of lighting through custom attributes baked during the merge.

HDR is a discipline, not a flag (longeron, marrow, derelict, breach)

The series employs toneMapped: false and relies on emissive values vibrant enough for effective Bloom halos. Throughout development, several issues revealed the demanding nature of HDR in Three.js:

By default, the composer buffer is 8-bit. An attempted fix kept causing a “bleed white” effect due to the frame buffer limiting emissive intensity. By setting frameBufferType={THREE.HalfFloatType}, these issues were resolved.

Bloom’s default blend is LDR dependent. Past attempts resulted in strange black artifacts due to improper blending modes. Switching to blendFunction={BlendFunction.ADD} ensured that emissions never inverted.

A grade can create NaN values. When using BrightnessContrast, values below zero can appear and lead to glitches. Adding a clamp effect immediately after the grade can prevent these issues, making blending far smoother.

Light that travels (flue, longeron)

The hallmark of this series is the dynamic movement of light through its environments. Each instance features a light source that travels along surfaces, creating engaging interactions. Two key principles developed during this process were crucial:

A wave’s visibility hinges on the fixture being OFF. The earlier method of keeping the entire strip illuminated while applying modulation obscured the movement’s clarity. A more effective technique involved lighting a single point, progressing along the strip to give the impression of movement. This discrete light strategy reinforced the travel effect.

Additive behavior beyond white. The lighting strategy involved overshooting white in the HalfFloat buffer, allowing scenarios where Bloom effects effectively haloed the light’s travel instead of simply brightening the fixture itself.

Moreover, maintaining a phased approach for each light source across strips ensured they appeared distinct rather than a single fabricated entity. Each light's timing springs from a consistent hashing of its position within the mesh, ensuring a cohesive but realistic display.

Lessons Learned and Future Directions

Every project reinforces key insights that shouldn't be overlooked. The underlying principles distilled from this work form a playbook for future endeavors. If you're navigating similar challenges, these takeaways could serve as invaluable guideposts.
  • Focus on pixel efficiency, not display resolution. Choosing a 5K monitor often leads to inflated costs. It’s more strategic to manage draw limits within sensible megapixel constraints.
  • Analyze the worst case, not the average. Relying on median performance often masks issues, particularly as vsync keeps figures propped at 60 fps long after the budget is spent.
  • Light counts matter in shader configurations. Each shader's cache needs to account for light intensity changes, not just their presence.
  • HDR processes must maintain their integrity. Breaking the chain of custody—say, by adjusting buffers mid-process—creates unexpected quality declines elsewhere.
  • Batch processing can negate per-object properties. To maintain functionality, be prepared to separate batches when necessary to keep flags effective.
  • Control parameters, not hardcoded fixes. Integrating adjustable toggles allows for quick localization of bugs, circumventing more time-consuming shader debugging.
  • A kick is not just a level measurement or a real-time concern. Pre-calibrate your data; this approach allows for a deliberate orchestration of audio and visuals rather than a chaotic reaction.
  • Data before assumptions. A seemingly insignificant bevel tweak cost-wise proved pivotal in performance optimization; it underscores the value of robust measurement.

Where to Explore Further

For those eager to test these principles in practice, the door remains open at several projects: explore FRONTIER, dive into the experimental LXSTNGHT shelf, or engage with /fm. These works stem from a creative sketchbook, evolving toward more polished, cinematic experiences. Keep an eye out for the upcoming deep dive into the intricacies of the FRONTIER freeze situation, where performance profiling and shader issues intertwine with the core development pipeline. Created with Three.js, React Three Fiber, pmndrs/postprocessing, GSAP, and Blender. Special thanks to the Three.js and pmndrs teams. Documenting these engineering trade-offs serves both as guidance and accountability.
Source: Filip Zrnzevic · tympanus.net

Comments

Sign in to comment.
No comments yet. Be the first to comment.

Related Articles

Sixty Frames for the Record: A Three.js Game, Seven Fly-T...