C64 READY.

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

Retro Vibes 3D viewer (src/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 — the Synthwave 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/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 close() (idempotent): the ✕ button (#btn-model-viewer-close), Esc (a passive keydown in main.js — keys are otherwise left live so you can type and watch the result on the modelled TV), or exiting native fullscreen (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.

The context is kept because WebKit does not reclaim a discarded WebGL context's memory (forceContextLoss() is not honoured there), so creating one per open let a few enter/exit cycles climb into the gigabytes and tripped Safari's "significant memory" tab reload. With one persistent context, teardown instead frees every per-open GPU resource explicitly — 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 — via _disposeObject(). (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), setting 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). Three 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 index (§5)
c64emu.modelViewerAutoRotate idle-spin 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(), returning { data: vic.frameBuffer, width: 384, height: 272 } while the machine runs, or 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/props to a group that is torn down wholesale on switch; an optional animate(group, t) runs each frame; envInt scales the IBL; 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 index.

# name Backdrop / mood Post pipeline
0 Synthwave gradient sky dome, banded Outrun sun, scrolling neon grid, wireframe mountains, palm silhouettes, magenta fog basic; raw tone map (NoToneMapping), glow faked in-shader
1 Starry Plain dark Tron-grid plain, twinkling star layers, procedural Milky-Way shader dome, hazy horizon, shooting stars basic; raw tone map
2 Spotlight near-black studio, soft overhead spotlight, screen-light spill onto the bezel full composer (bloom dialled near-off + grade + SMAA)
3 IK+ Sunset torii gate on a dark shore, striped low sun + halo, reflective ocean (THREE.Water), blossom tree, hut, headlands basic; ACES exposure 0.62, purple haze fog
4 Arcade dark room, coloured lights orbiting + pulsing around the machine, glossy reflective floor basic
5 80s Room wood-panelled room, shag carpet, popcorn ceiling, night window (moonlight), flickering amber dome, drifting dust basic

Scenes flagged basic render with a plain renderer.render and bypass the composer; so the bloom + grade + SMAA pipeline only runs for the non-basic scene (Spotlight), while Synthwave and Starry Plain further use raw tone mapping and bake their glow into their own shaders. Backdrop gradients are cached equirect CanvasTextures; the procedural props (room textures, star sprites, sunset sky/sun, water normal map, checker floor) are built once and cached at module level.

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 + mutes for the first per-eye shader compile, disables the orbit controls, hides the 2D overlay, snapshots the desktop camera pose, and 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 — 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, chosen once at module load by resolveModelUrl():

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 SIZE (#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, LARGE forces the 4K build, and 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 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('./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 { 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