From d6131bb7cbe0595804eada953e849eecbbf6eda5 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Tue, 24 Mar 2026 00:06:23 -0400 Subject: [PATCH] feat: add photo mode with camera controls and depth of field (#134) - Press P to toggle photo mode (hides all HUD elements) - OrbitControls enabled in photo mode for free camera navigation - BokehPass post-processing provides depth of field effect - [ ] keys adjust focus distance; live focus readout shown in indicator - Scene rotation frozen in photo mode for stable composition - Minimal on-screen indicator shows controls; disappears on exit Fixes #134 --- app.js | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++--- index.html | 5 ++++ style.css | 44 ++++++++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 3 deletions(-) diff --git a/app.js b/app.js index 5a028f6..4902f2f 100644 --- a/app.js +++ b/app.js @@ -1,4 +1,8 @@ import * as THREE from 'three'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; +import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'; +import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'; +import { BokehPass } from 'three/addons/postprocessing/BokehPass.js'; // === COLOR PALETTE === const NEXUS = { @@ -144,11 +148,75 @@ document.addEventListener('keydown', (e) => { } }); +// === PHOTO MODE === +let photoMode = false; + +// Post-processing composer for depth of field +const composer = new EffectComposer(renderer); +composer.addPass(new RenderPass(scene, camera)); + +const bokehPass = new BokehPass(scene, camera, { + focus: 5.0, + aperture: 0.0003, + maxblur: 0.008, +}); +bokehPass.enabled = false; +composer.addPass(bokehPass); + +// Orbit controls for free camera movement in photo mode +const orbitControls = new OrbitControls(camera, renderer.domElement); +orbitControls.enableDamping = true; +orbitControls.dampingFactor = 0.05; +orbitControls.enabled = false; + +const photoIndicator = document.getElementById('photo-indicator'); +const photoFocusDisplay = document.getElementById('photo-focus'); + +/** + * Updates the photo mode focus distance display. + */ +function updateFocusDisplay() { + if (photoFocusDisplay) { + photoFocusDisplay.textContent = bokehPass.uniforms['focus'].value.toFixed(1); + } +} + +document.addEventListener('keydown', (e) => { + if (e.key === 'p' || e.key === 'P') { + photoMode = !photoMode; + document.body.classList.toggle('photo-mode', photoMode); + bokehPass.enabled = photoMode; + orbitControls.enabled = photoMode; + if (photoIndicator) { + photoIndicator.classList.toggle('visible', photoMode); + } + if (photoMode) { + // Sync orbit target to current look-at + orbitControls.target.set(0, 0, 0); + orbitControls.update(); + updateFocusDisplay(); + } + } + + // Adjust focus with [ ] while in photo mode + if (photoMode) { + const focusStep = 0.5; + if (e.key === '[') { + bokehPass.uniforms['focus'].value = Math.max(0.5, bokehPass.uniforms['focus'].value - focusStep); + updateFocusDisplay(); + } else if (e.key === ']') { + bokehPass.uniforms['focus'].value = Math.min(200, bokehPass.uniforms['focus'].value + focusStep); + updateFocusDisplay(); + } + } +}); + // === RESIZE HANDLER === window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); + composer.setSize(window.innerWidth, window.innerHeight); }); // === ANIMATION LOOP === @@ -168,8 +236,8 @@ function animate() { camera.position.lerpVectors(NORMAL_CAM, OVERVIEW_CAM, overviewT); camera.lookAt(0, 0, 0); - // Slow auto-rotation — suppressed during overview so the map stays readable - const rotationScale = 1 - overviewT; + // Slow auto-rotation — suppressed during overview and photo mode + const rotationScale = photoMode ? 0 : (1 - overviewT); targetRotX += (mouseY * 0.3 - targetRotX) * 0.02; targetRotY += (mouseX * 0.3 - targetRotY) * 0.02; @@ -182,7 +250,12 @@ function animate() { // Subtle pulse on constellation opacity constellationLines.material.opacity = 0.12 + Math.sin(elapsed * 0.5) * 0.06; - renderer.render(scene, camera); + if (photoMode) { + orbitControls.update(); + composer.render(); + } else { + renderer.render(scene, camera); + } } animate(); diff --git a/index.html b/index.html index 26344f3..6d4000f 100644 --- a/index.html +++ b/index.html @@ -41,6 +41,11 @@ [Tab] to exit +
+ PHOTO MODE + [P] exit  |  [[] focus-   []] focus+   focus: 5.0 +
+ diff --git a/style.css b/style.css index 1d78e52..92029bb 100644 --- a/style.css +++ b/style.css @@ -106,3 +106,47 @@ canvas { 0%, 100% { opacity: 0.7; } 50% { opacity: 1; } } + +/* === PHOTO MODE === */ +body.photo-mode .hud-controls { + display: none; +} + +body.photo-mode #overview-indicator { + display: none !important; +} + +#photo-indicator { + display: none; + position: fixed; + bottom: 16px; + left: 50%; + transform: translateX(-50%); + color: var(--color-primary); + font-family: var(--font-body); + font-size: 11px; + letter-spacing: 0.2em; + text-transform: uppercase; + pointer-events: none; + z-index: 20; + border: 1px solid var(--color-primary); + padding: 4px 12px; + background: rgba(0, 0, 8, 0.5); + white-space: nowrap; + animation: overview-pulse 2s ease-in-out infinite; +} + +#photo-indicator.visible { + display: block; +} + +.photo-hint { + margin-left: 12px; + color: var(--color-text-muted); + font-size: 10px; + letter-spacing: 0.1em; +} + +#photo-focus { + color: var(--color-primary); +} -- 2.43.0