Realtime Cube EnvMap
A `CubeCamera` sits inside the reflector and renders the six directions around it every other frame; that cube texture is assigned straight onto the material as its `envMap`. Because the panels supplying the reflection are orbiting, the highlights crawl across the surface instead of being painted on. No HDRI, no loader, no asset — the environment is scene geometry.
Realtime Cube EnvMap
Live preview
The real component, running in this page.
Install
Copies the source into your project. Nothing is imported from us at runtime.
npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/realtime-cube-envmap.jsonAlso installs webgl, use-raf, use-in-view, use-visibility, use-resize-observer, use-reduced-motion from this registry.
Prompt
The whole technique, written for an agent.
Build it with your agent
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
Source
7 files, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| shape | 'knot' | 'icosahedron' | = 'knot' | Reflector silhouette. The knot has more curvature and so more reflection per pixel. |
| panelCount | number | = 7 | Emitting panels on the ring. Odd counts keep bright and dark panels from pairing off across the object. |
| panelRadius | number | = 4.2 | Ring radius in world units. |
| spinSpeed | number | = 0.18 | Panel ring rotation, radians per second. This is what makes the reflection move. |
| objectSpin | number | = 0.32 | Reflector rotation about Y, radians per second. X spins at 0.55 of this. |
| resolution | number | = 256 | Cube face size in pixels. Cost is six faces plus mips. |
| refreshEvery | number | = 2 | Capture the cube map every Nth rendered frame. 1 captures every frame. |
| roughness | number | = 0.08 | Surface roughness, which selects the env map mip level. |
| metalness | number | = 1 | 1 is a mirror; 0 is diffuse and lit only by the env map. |
| envMapIntensity | number | = 1.4 | Multiplier on reflected light. |
| hue | number | = 208 | Base hue in degrees for the panels and the shell gradient. |
| className | string | — | Merged onto the host element via cn. |
How it works
The technique in order, not the code in order.
A
WebGLCubeRenderTarget(resolution, { generateMipmaps: true, minFilter: LinearMipmapLinearFilter })backs aCubeCamera(0.1, 60, target)positioned at the reflector, andcubeTarget.textureis assigned tomaterial.envMaponce at build time — the texture object never changes identity, only its contents.The environment is built from geometry: an inside-out
SphereGeometry(16)withside: BackSidecarrying a vertical gradientCanvasTexture, pluspanelCountPlaneGeometryemitters on a ring of radiuspanelRadius, eachlookAt(0,0,0)and lifted bysin(i * 2.39996) * 0.9so the reflection has vertical structure to move through.Panels alternate bright and near-black via
color.setHSL(hue + i*26, i%2 ? 0.4 : 0.66, i%2 ? 0.08 : 0.62). The dark ones are load-bearing: a uniformly bright environment gives a mirror nothing to contrast against and the metal reads as matte plastic.Each frame the ring and the reflector are rotated from
elapsed, then — only on frames whereframes % refreshEvery === 0— the reflector is setvisible = false,cubeCamera.update(renderer, scene)runs, and it is set visible again before the single visiblerenderer.render.Resize lives in its own effect: it sets renderer size, sets
camera.aspect, moves the camera toBASE_DISTANCE / Math.min(1, aspect)so a portrait container does not clip the panel ring off both sides, and forces one capture plus one redraw so a reflow never leaves a stale or black frame on screen.Teardown disposes the shell texture, both geometries, every panel material, the reflector material, then
cubeTarget.dispose()— the cube target is not in the scene graph, sodisposeObjectnever reaches it — and finallydisposeRenderer, which is what callsforceContextLoss().
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| refreshEvery | = 2 | Cube captures per rendered frame. Six faces is six scene renders against the visible pass's one, so this is 6/7 of the component's cost. An env map is sampled by direction, not position, so at these rotation speeds a one-frame-old capture is not observable; at 4 or more the highlights visibly step. |
| resolution | = 256 | Cube face size. Cost is 6 x resolution² fragments plus a mip chain — about 2 MB of GPU memory. At 128 the panel edges alias into the reflection as jagged bands; above 512 nothing improves because roughness immediately blurs the detail away. |
| roughness | = 0.08 | Not 0. A perfect mirror shows the panels as hard-edged rectangles and every seam in the cube map becomes visible; a slight roughness selects a lower mip and softens exactly those seams. Above ~0.3 the reflection stops being readable as a scene at all. |
| envMapIntensity | = 1.4 | Reflected light multiplier. At 1 a dark environment leaves the object nearly invisible against the shell; above ~2 the bright panels clip to flat white and the curvature information in the highlight is lost. |
| panelRadius | = 4.2 | Ring radius against a ~1.4-unit reflector. Closer and the panels enter the camera frustum and read as walls rather than as light sources; further and they subtend too small an angle to produce a highlight worth reflecting. |
| BASE_DISTANCE | = 7.2 | Camera distance at aspect >= 1, chosen against a 34 degree field of view so the reflector fills about two thirds of the frame height. Divided by min(1, aspect) on narrower containers. |
| STATIC_POSE | = 0.9 | The elapsed value used for the reduced-motion still and for post-resize redraws. Not 0: at zero the knot is axis-aligned and the panels sit on the cardinal directions, which reads as a technical diagram instead of a lit object. |
| lift stagger | = sin(i * 2.39996) * 0.9 | 2.39996 rad is the golden angle, so successive panels never repeat a height for any panel count. A constant height puts every panel in one band and the reflection has nothing to travel through vertically. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
The rAF subscription is never made, and the scene is drawn once at STATIC_POSE with a forced cube capture — a fully reflective still with the panels off the cardinal axes. Reflections, gradient shell and material are all exactly as they are in motion; only the rotation is absent. Resizing redraws the same pose rather than resuming.
disposeObject(scene) walks the graph, then the shell canvas texture, shell and panel geometries, every panel material and the reflector material are disposed explicitly, then cubeTarget.dispose() releases the six faces and their mip chain, then disposeRenderer(renderer) calls dispose() and forceContextLoss() and zeroes the backing store, and the canvas node is removed. The CubeCamera is removed from the scene first so nothing holds a reference to a disposed target.
The bright bands on the object crawl across its surface continuously while it turns, and the same colours are visible on the panels orbiting behind it. There is no dark recursive tunnel anywhere on the surface at any moment. Raising roughness toward 0.3 progressively blurs the reflection instead of leaving it a hard mirror. Resize the container and the object stays fully in frame in both landscape and portrait. Mount and unmount a dozen instances and none of the earlier ones go black.
Pitfalls
- Capturing with the reflector visible. It photographs its own previous frame, the next capture includes that photograph, and within about a second the surface is a recursive tunnel of dark shells. Hide it, capture, show it — inside the same frame, so it is never absent from the visible render.
- Creating the cube target without mipmaps.
roughnesspicks a mip level of the env map; with only level 0 available the surface stays a hard mirror at every roughness value and the prop appears to do nothing. - Refreshing the cube map every frame because it looks like the honest thing to do. That is seven scene renders per displayed frame, and it is the difference between a preview grid that holds 60fps and one that does not.
- Rendering the first frame without forcing a capture. An unwritten cube map is black, a metal surface reflecting black is black, and on a throttled refresh that black sits on screen for
refreshEveryframes as a visible flash. - Building the environment out of bright panels only. Metal is read entirely from the contrast between light and dark in its surroundings; with no dark panels the highlights have no edges and the material looks like painted plastic.
- Letting
disposeObject(scene)stand in for cube-target cleanup. The target is not a child of anything, so nothing traverses it, and six faces plus mips leak per mount — which under StrictMode double-invocation is two leaks per page load. - Leaving the camera distance fixed. Framing on height alone means a portrait container crops the panel ring away entirely, and the object loses the thing it is reflecting.
Notes
- No lights in the scene.
metalness: 1withroughnessnear 0 is lit purely by its environment, so a light would contribute almost nothing while adding a uniform to every shader compile. transmissionis deliberately not exposed. Three renders a separate full-scene pass per transmissive object, which on top of six cube faces triples the frame cost for an effect the cube map already implies.- The panel arrangement is a pure function of the index, so there is no RNG and nothing to seed — server and client markup cannot disagree.