█▓▒░C64 READY.

Docs / Retro Vibes 3D viewer (src/vibes/retrovibes.js): Architecture Overview

Retro Vibes 3D viewer (src/vibes/retrovibes.js): Architecture Overview

Retro Vibes is a browser-window-filling three.js scene that shows the Commodore 64 as a 3D model (the breadbin, a 1541 disk drive and a 1702 monitor) lit like an 80s synthwave demo, with the live emulator picture playing on the modelled monitor's CRT. Drag to rotate, scroll / two-finger pinch to zoom, double-click to power the machine on, Esc or the ✕ to close.

Retro Vibes: a spotlit scene, with the live C64 READY. screen playing on the modelled 1702 monitor.

Just want to use it? See Getting started §7 and the Features tour. This document is the implementation deep-dive.

The whole viewer is the ModelViewer class, constructed once around the #model-viewer-overlay element. It is self-contained: src/main.js only ever calls open() / close() and wires a handful of callbacks (§9); the WebGL context, the glTF model, the scenes, the post-processing and WebXR all live inside ModelViewer. It shares one trait with the powered-off attract animation in src/vibes/pausedemo.js: both lazy-import three.js on first use so the library never bloats the main bundle (§10).

The glTF model is "Commodore 64 || Computer (Full Pack)" by dark_igorek, CC BY 4.0, credited in the overlay's ⓘ model-credit popup.

1. Opening & closing

The 🌇 VIBES button (#btn-vibes, in Controls) opens the viewer. main.js lazy-imports the module, builds the ModelViewer once, then calls open():

Closing happens three ways, all routed to the idempotent close(): the ✕ button (#btn-model-viewer-close), Esc, or exiting native fullscreen. Esc is an escape-stack layer pushed by open(), so the overlay outranks any dialog beneath it; there is no keydown listener of its own, and other keys stay live so you can type and watch the result on the modelled TV. A fullscreenchange handler closes the viewer when fullscreen is lost, unless it was left deliberately to enter VR. close() persists the camera, hides the overlay, exits fullscreen, stops the loop (setAnimationLoop(null)), then _teardownGL() disposes the per-open scene while keeping the renderer and its GL context alive. The next open() rebuilds only the scene and reloads the model onto the same context; the module-level procedural texture caches and the IBL cubemap survive (tagged _shared), resident across opens.

Why the context is kept: WebKit does not reclaim a discarded WebGL context's memory (forceContextLoss() is not honoured there), so a context per open lets a few enter/exit cycles climb into the gigabytes and trip Safari's "significant memory" tab reload.

What teardown frees instead, explicitly, via _disposeObject(): geometry, materials and their textures, each post-processing pass's render targets (three's EffectComposer.dispose() frees only its own ping-pong targets, not the passes), reflector/shadow-map render targets, and instanced buffers. (One residual: a THREE.Water reflection target in the IK+ scene, which Water exposes no handle to free.) A lost context is recovered by a webglcontextrestored handler that rebuilds the open scene.

2. The three.js scene

_initGL() builds everything once:

3. Framing, zoom-to-monitor & the persisted camera

_frameModel() measures the model's Box3 + bounding sphere and pulls the camera back to dist = (r / sin(fov/2)) × 0.56; the sub-1 factor crops slightly into the sphere so the flat C64 silhouette fills the frame. It sets the dolly limits (minDistance = r×0.2, maxDistance = dist×4) and near/far to suit.

The default view orbits and dollies around the live screen: the orbit target is the centre of the monitor_screen mesh, and because zoomToCursor is off, zooming moves the camera straight toward the monitor; the CRT is the star. If a saved view exists it is restored instead (dolly limits are widened so the saved distance is not clamped away). Five small localStorage keys persist viewer state:

Key Holds
c64emu.modelViewerCamera camera position + orbit target (_saveCamera, on the controls end event and on close)
c64emu.modelViewerScene active scene, by name (§5)
c64emu.modelViewerFog Foggy Spotlight's fog switch, '0' when off (§5)
c64emu.modelViewerAutoRotate idle-spin on/off (§6)
c64emu.modelViewerStudio Studio mode on/off (§6)

4. The live screen: emulator framebuffer → CRT texture

This is the defining integration: the running C64 picture appears on the modelled 1702's glass. It is set up by _wireScreen() and refreshed every frame by _updateScreen():

main.js supplies the provider via setScreenProvider(), reusing one { data: vic.frameBuffer, width: 384, height: 272 } object while the machine runs, or returning null otherwise.

5. Scenes (moods)

Each entry in the module-level SCENES array keeps the same model + camera and only changes the lighting, surroundings and backdrop. A scene's build(group, ctx) adds its lights and props to a group that is torn down wholesale on switch, and an optional animate(group, t) runs each frame. envInt scales the IBL, and a scene may replace the shared neutral RoomEnvironment PMREM with its own via envMap(renderer); IK+ bakes one from its sunset sky. bg / fog / tone / exposure / bloom tune the look. The 🎬 button (#btn-model-viewer-scene) calls nextScene() → _applyScene(), which disposes the old group, builds the new one, applies its env-intensity / tone-mapping / background / fog / bloom, and persists the scene's name. vibes-scene-pref.js reads it back, and also reads the scene position earlier versions stored, against the scene list they had, so a remembered scene survives scenes being added or removed. A removed scene opens as the one that replaced it (Spotlight as Foggy Spotlight).

# name Backdrop / mood Post pipeline
0 Synthwave neon highway driving toward a banded sun behind cloud streaks: wet mirror road, modelled palms and contoured mountains (a GLB, below), twinkling stars, a shooting star full composer (bloom on neon pushed past 1.0, Khronos PBR Neutral tone mapping), a sunset IBL via envMap, inverse-square neon lights scaled by model radius squared
1 Starry Plain dark Tron-grid plain: scanner ripple, star layers, a procedural Milky-Way band, shooting stars basic; raw tone map
2 Foggy Spotlight Cycles-baked studio floor (a GLB, below): theatre spotlight, stage smoke in the beam (switchable), beam dust, live monitor spill full composer with smoke and contact shadows, Khronos PBR Neutral tone mapping, a Cycles light probe via envMap
3 IK+ Sunset stone courtyard at dusk: torii and low sun over reflective water, autumn maple, layered headlands full composer (bloom + warm halation + dusk split-tone grade); cool-shadow sunset IBL via envMap; ACES exposure 0.66, purple haze fog
4 80s Bedroom modelled teenager's bedroom at night (a GLB, below): amber desk lamp, lava lamp, live monitor spill, a flickering wood-grain TV, a half moon through the blinds, a moonbeam, a lit landing full composer (bloom + amber halation + teal/amber grade)

Scenes flagged basic render with a plain renderer.render and bypass the composer; the bloom + grade + SMAA pipeline runs for the non-basic scenes (Synthwave, Foggy Spotlight, IK+ Sunset, 80s Bedroom), while Starry Plain further uses raw tone mapping and bakes its glow into its own shaders. Backdrop gradients are cached equirect CanvasTextures; the procedural props (star sprites, sunset sky/sun, water normal map, checker floor) are built once and cached at module level.

Synthwave

Foggy Spotlight

A theatre spotlight with stage smoke in its beam, photographic. Its surroundings come from files in public/models/, made in Blender around the C64 model, which is never moved:

The 80s Bedroom

The 80s Bedroom is built from a model file too: public/models/bedroom.glb, modelled in Blender, with the C64 placed by the viewer as in every scene and the room fitted around it (carpet 0.72 m below the model, R * 1.04 world units per metre, the room 6 cm behind the model's centre so the monitor's cables lie on the desk). vibes-scene-bedroom.js loads it.

Bedroom and Foggy Spotlight set staticShadows. vibes-shadow-cache.js watches the casters, their ancestors, shadow lights and light targets. Transforms, visibility, vertex/index buffer versions (including animated keycaps), instanced matrices and shadow camera parameters invalidate the maps. Camera orbit, light intensity and non-casting dust do not. Scene switches rebuild dependencies and request fresh maps; reparenting tracked objects rebuilds the dependencies too. Code adding casters to an existing scene must call the cache's rebuild().

A scene whose casters move only slowly sets shadowHz instead (ShadowThrottle): its maps refresh at that rate rather than every frame. IK+ uses 10 Hz, since only the maple's sway moves a caster, and at that rate the sway shifts its shadow by a fraction of a texel per refresh.

IK+ (water) and Synthwave (road mirror) refresh their offscreen reflection at 30 Hz, reducing the extra scene render. The water's wave time still advances every display frame. Projection changes and XR eye renders bypass the rate limit. Outside XR the viewer refreshes the mirror before the main render, not inside it: three keeps one light state per render nesting depth, and a nested mirror makes every lit material rebuild its program parameters each frame.

Every draw costs three a round of matrix and colour uploads that allocate, so IK+ builds its props from many primitives and then folds the still, opaque ones into one mesh per material and shadow setup (mergeStatic in vibes-scene-common.js). The swaying maple merges within its own group, and what animates on its own (gulls, falling leaves, the fish) and what blends (ripples, foam) keeps its own mesh. Starry keeps one hidden meteor, reusing its geometry, material and direction vector; its maximum lifetime is shorter than the minimum spawn interval.

6. Interactions on the model

LED Location Driven by
C64 power (red) case top, by the badge machine powered on
1541 power (green) drive front, bottom-left machine powered on
1541 read/activity (red) drive front, at the slot 1541 read/activity
1702 power (green) monitor front lamp window machine powered on

7. WebXR VR mode

Immersive-VR renders the scene in stereo with head tracking. The 🥽 ENTER VR button (#btn-model-viewer-vr) starts hidden and _initVrButton() reveals it only when navigator.xr.isSessionSupported('immersive-vr') resolves true, i.e. a real headset or the WebXR emulator browser extension, so it never appears on desktop without VR or on any phone / tablet. It stays disabled until the model is loaded and framed, because the rig is seeded from the current camera.

_toggleVr() first exits DOM element-fullscreen (in fullscreen the browser will not route pointer events to elements outside the fullscreen element, which would leave the injected page controls visible but dead), suppressing the auto-close, then requests immersive-vr with the optional local-floor feature. On sessionstart, _onXrStart() pauses and mutes for the first per-eye shader compile, disables the orbit controls, hides the 2D overlay and snapshots the desktop camera pose. It places the rig at that world pose so VR continues from the exact 3D view (1:1 scale) with the headset pose added on top. A plain hemisphere fill light is added because VR skips the composer. On sessionend, _onXrEnd() synchronously restores the desktop camera (position, orientation, fov, target), so no headset pose is ever persisted, then re-enables controls and re-grabs fullscreen. In VR, _loop always uses a plain renderer.render (the EffectComposer is not XR-compatible), so the neon post-processing is skipped and the headset view is flatter than the 2D view.

8. Screen-adaptive model resolution

The model ships in two GLB builds. _loadModel() calls resolveModelUrl() when loading the model, so reopening the viewer uses the current preference:

resolveModelUrl() defaults to the lighter commodore_64.glb on every device; the 4K build's large single-open memory peak makes the light model the safe default. The user can change this in Options ▸ Display ▸ 3D MODEL (#btn-vibes-model), which cycles SMALL → AUTO → LARGE and stores the choice under c64emu.vibesModel, read fresh on each open. SMALL (default) forces the light build and LARGE the 4K build. AUTO defers to autoWantsLargeModel(): 4K only when the longest screen dimension is ≥ 1024 CSS px and the device is not touch (navigator.maxTouchPoints > 0 / ontouchstart), so phones and tablets get the light asset. The same choice, read through wantsLargeModels(), picks the 80s Bedroom's room files (§5). The path is resolved against Vite's BASE_URL so it stays correct under a non-root deploy. GLTFLoader reports load progress into the overlay's LOADING MODEL… label.

9. Integration with main.js

main.js builds the viewer lazily (_ensureModelViewer() → import('./vibes/retrovibes.js')) and injects everything it needs through setters, reading the machine fresh each call because it is re-created on power / reset:

Setter What main.js supplies Used for
setScreenProvider reused { data: vic.frameBuffer, width: 384, height: 272 } while running, else null live CRT texture (§4)
setOnDoubleClick power the C64 on when off (powerBtn.click()) double-click-to-boot (§6)
setPowerProvider running C64 / 1541 / monitor power LEDs (§6)
setDriveActiveProvider `drive1541.ledOn
setTouchControls the existing #touch-controls element keep the touch joystick inside the active fullscreen element
setBusyHooks pause + mute / resume, the same mechanism as the PAUSE button freeze the machine across heavy transitions

The fullscreen API only displays and routes pointer input to descendants of its fullscreen element. On open, the viewer therefore moves the existing touch-control element from the 2D monitor into its overlay before requesting fullscreen; on every close path it restores the element to its original DOM position. The element keeps the same listeners and joystick state throughout.

The busy hooks matter for audio: open(), close(), scene swaps and VR entry all stall the main thread (GL init, model load, scene build, first shader compile, exit-fullscreen reflow), which would starve the SID AudioWorklet's ring buffer and make the sound jerk. ModelViewer holds the machine paused across each transition and resumes only once the scene has actually rendered a couple of frames and the resizes have settled (_loop's busy-exit logic); close() resumes off a short timer since the loop is already stopped.

10. Performance notes

11. Key invariants & gotchas (quick reference)