From faf317bef3553c60e8dfc6df95550c38b9d7b8b5 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Tue, 24 Mar 2026 00:14:59 -0400 Subject: [PATCH] feat: add heat distortion effect near energy sources (#115) - Add 3 glowing energy orb meshes with point lights placed close to the camera (visible from default position) - Add custom ShaderPass using value noise to warp UV coordinates in screen regions near each orb, simulating heat shimmer - Energy source world positions are projected to UV space each frame and passed as uniforms; sources behind the camera are parked off- screen so their falloff is zero - Orbs pulse in opacity and light intensity for a breathing energy feel - Heat distortion pass is inserted after the existing BokehPass in the EffectComposer chain Fixes #115 Co-Authored-By: Claude Sonnet 4.6 --- app.js | 110 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/app.js b/app.js index 14b95c0..27825e9 100644 --- a/app.js +++ b/app.js @@ -3,6 +3,7 @@ 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'; +import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js'; // === COLOR PALETTE === const NEXUS = { @@ -162,6 +163,95 @@ const bokehPass = new BokehPass(scene, camera, { }); composer.addPass(bokehPass); +// === ENERGY SOURCES === +// Three floating orbs that emit light and drive the heat distortion effect. +const ENERGY_SOURCE_POSITIONS = [ + new THREE.Vector3(2.5, 0.8, -0.5), + new THREE.Vector3(-2.0, 1.2, 0.8), + new THREE.Vector3(0.3, -1.8, 1.5), +]; + +const energyOrbs = ENERGY_SOURCE_POSITIONS.map((pos) => { + const geo = new THREE.SphereGeometry(0.12, 16, 16); + const mat = new THREE.MeshBasicMaterial({ color: 0x66ccff, transparent: true, opacity: 0.9 }); + const orb = new THREE.Mesh(geo, mat); + orb.position.copy(pos); + scene.add(orb); + + const light = new THREE.PointLight(0x4499ff, 1.8, 7); + light.position.copy(pos); + scene.add(light); + + return { orb, mat, light }; +}); + +// === HEAT DISTORTION PASS === +const HEAT_SOURCE_COUNT = ENERGY_SOURCE_POSITIONS.length; // 3 — must match GLSL literal +const heatDistortionShader = { + uniforms: { + tDiffuse: { value: null }, + uTime: { value: 0.0 }, + uSourcePositions: { value: ENERGY_SOURCE_POSITIONS.map(() => new THREE.Vector2()) }, + }, + vertexShader: /* glsl */` + varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + fragmentShader: /* glsl */` + uniform sampler2D tDiffuse; + uniform float uTime; + uniform vec2 uSourcePositions[3]; + varying vec2 vUv; + + // Value noise — smooth, cheap, tileable-ish + float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); + } + float vnoise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix( + mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x), + mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), f.x), + f.y + ); + } + + void main() { + vec2 uv = vUv; + float dx = 0.0; + float dy = 0.0; + + for (int i = 0; i < 3; i++) { + float dist = length(uv - uSourcePositions[i]); + float radius = 0.24; + float falloff = max(0.0, 1.0 - dist / radius); + falloff = falloff * falloff * falloff; + + // Two octaves of noise — horizontal and vertical shimmer + float n1 = vnoise(uv * 8.0 + vec2( uTime * 0.35, uTime * 0.70)); + float n2 = vnoise(uv * 11.0 - vec2( uTime * 0.55, uTime * 0.25)); + + dx += (n1 - 0.5) * 0.008 * falloff; + // Heat rises — stronger vertical component + dy += (n2 - 0.5) * 0.014 * falloff; + } + + gl_FragColor = texture2D(tDiffuse, uv + vec2(dx, dy)); + } + `, +}; + +const heatDistortionPass = new ShaderPass(heatDistortionShader); +composer.addPass(heatDistortionPass); + +// Reusable NDC vector for per-frame source projection +const _ndcPos = new THREE.Vector3(); + // Orbit controls for free camera movement in photo mode const orbitControls = new OrbitControls(camera, renderer.domElement); orbitControls.enableDamping = true; @@ -255,6 +345,26 @@ function animate() { // Subtle pulse on constellation opacity constellationLines.material.opacity = 0.12 + Math.sin(elapsed * 0.5) * 0.06; + // Pulse energy orbs and update heat distortion uniforms + heatDistortionPass.uniforms.uTime.value = elapsed; + energyOrbs.forEach(({ orb, mat, light }, i) => { + const pulse = 0.6 + 0.4 * Math.sin(elapsed * 1.8 + i * 2.1); + mat.opacity = 0.55 + 0.45 * pulse; + light.intensity = 1.2 + 1.0 * pulse; + + // Project world position → UV space (0..1) for the shader + _ndcPos.copy(orb.position).project(camera); + if (_ndcPos.z > 1.0) { + // Behind the camera — park far off-screen so falloff = 0 + heatDistortionPass.uniforms.uSourcePositions.value[i].set(-10, -10); + } else { + heatDistortionPass.uniforms.uSourcePositions.value[i].set( + (_ndcPos.x + 1) / 2, + (_ndcPos.y + 1) / 2 + ); + } + }); + if (photoMode) { orbitControls.update(); } -- 2.43.0