3D interfaces often include HTML overlays that follow objects in the scene: annotations, notes, labels, or contextual controls. They may look like a few simple DOM elements, but if their position and visibility are recalculated through expensive paths every frame, camera movement can become noticeably sluggish even when the 3D model itself is not especially heavy.
I ran into this in a notes prototype. The scene contained only seven notes, each made of an anchor, a leader line, and a text label. Basic self-testing did not make the issue feel severe, but once someone continuously rotated the camera, the first piece of feedback was that the scene felt very slow.
The useful question was not "is the model too heavy?" but what exactly is happening every frame while the camera is moving?
The main cost came from two operations that both ran continuously during camera movement.
The first was re-rendering the overlays through React every frame. The original implementation recalculated overlay positions and called setOverlay(list) on every frame. React then re-rendered all seven overlay components, which also included relatively expensive CSS such as text strokes and icon shadows.
The issue was not the number of overlays. It was that high-frequency position updates were being routed through the React render cycle.
The second was raycasting each note against the full model for occlusion. The visibility check used raycaster.intersectObjects(modelMeshes). The model was still around 11MB after compression and had no BVH acceleration structure. Repeating full mesh intersection tests for seven notes on every frame added another significant cost during camera motion.
Together, these two paths kept the main thread busy throughout the interaction.
Instead of trying to make the original per-frame loop slightly cheaper, the two operations were moved to more appropriate mechanisms.
1. Move overlay positioning out of React. React now handles changes that actually affect the overlay content or state — adding, deleting, or editing notes; selection changes; entering or leaving a move state. Per-frame positioning during camera movement is handled by writing directly to the DOM:
el.style.transform = `translate(${x}px, ${y}px) translate(-50%, -100%)`Using transform also avoids the layout cost associated with repeatedly changing left and top. The separation is simple: React decides what the overlay is, while the render loop only updates where it is. Camera movement no longer causes continuous React re-renders.
2. Replace exact occlusion with orientation culling. The second change was to reduce the precision requirement of the visibility test. The original implementation asked a strict geometric question: is this note occluded by the model? For this feature, many notes sit directly on the model surface, so a cheaper approximation is often enough: is the surface facing the camera or facing away from it?
The revised version uses the surface normal and camera direction. Notes on back-facing surfaces are hidden; notes on front-facing surfaces remain visible.
This is not exact geometric occlusion, but it is sufficient for the current interaction and much cheaper than repeated ray intersections against a complex mesh. If the scene later requires strict occlusion, a structure such as BVH would be the more appropriate next step.
The performance work happened in two rounds.
The first round introduced a dirty-flag gate. When neither the camera nor the notes had changed, the frame could skip the overlay update entirely. That did reduce wasted work while the scene was idle, but user testing still reported noticeable lag.
The reason was straightforward: the problematic interaction was continuous camera rotation. During that operation, the scene is never "clean," so all of the expensive per-frame work still runs.
This made the distinction clearer: reducing unnecessary frames and reducing the cost of an active frame are different optimization problems. The first helps when the scene is idle. The second determines whether continuous interaction feels smooth.
There was also a difference between functional testing and performance testing.
A common self-test is to click once, rotate briefly, and confirm that the feature behaves correctly. That is usually enough to catch functional errors, but it may not expose sustained performance cost.
Performance testing needs to keep the interaction active: rotate the camera continuously, zoom repeatedly, or drag for long enough that the per-frame work stays under pressure.
The first optimization looked better during casual testing, but it did not solve the real user scenario. Whether a performance fix is successful should be judged in the interaction that originally felt slow.
The technical diagnosis and the specific implementation changes mainly came from Claude Code. My role was to confirm the real performance scenario, test the revised behavior, and decide whether the interaction actually felt improved.
The useful lesson for me was that a technically reasonable optimization is not automatically a successful one. The first pass reduced wasted computation, but it did not address the part of the interaction users actually perceived as slow. Performance work only becomes meaningful when it is validated against the real operation that caused the problem.