import * as THREE from 'https://esm.sh/three@0.160.0'; import { OrbitControls } from 'https://esm.sh/three@0.160.0/examples/jsm/controls/OrbitControls.js'; import { EffectComposer } from 'https://esm.sh/three@0.160.0/examples/jsm/postprocessing/EffectComposer.js'; import { RenderPass } from 'https://esm.sh/three@0.160.0/examples/jsm/postprocessing/RenderPass.js'; import { UnrealBloomPass } from 'https://esm.sh/three@0.160.0/examples/jsm/postprocessing/UnrealBloomPass.js'; import { SSAOPass } from 'https://esm.sh/three@0.160.0/examples/jsm/postprocessing/SSAOPass.js'; // === SCENE SETUP === const scene = new THREE.Scene(); scene.background = new THREE.Color(0x000000); const camera = new THREE.PerspectiveCamera(60, window.innerWidth/window.innerHeight, 0.1, 1000); camera.position.set(0, 30, 40); camera.lookAt(0, 0, 0); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.shadowMap.enabled = true; renderer.toneMapping = THREE.NoToneMapping; renderer.outputColorSpace = THREE.LinearSRGBColorSpace; document.body.appendChild(renderer.domElement); const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; // === POSTPROCESSING === const composer = new EffectComposer(renderer); composer.addPass(new RenderPass(scene, camera)); composer.addPass(new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 0.23, 0.4, 0.2)); // === LIGHTING === const light = new THREE.DirectionalLight(0xffffff, 1); light.position.set(10, 20, 10); light.castShadow = true; light.shadow.mapSize.width = 1024; light.shadow.mapSize.height = 1024; light.shadow.camera.near = 0.5; light.shadow.camera.far = 100; light.shadow.camera.left = -50; light.shadow.camera.right = 50; light.shadow.camera.top = 50; light.shadow.camera.bottom = -50; scene.add(light); // === MOUSE === const mouse = new THREE.Vector2(); const raycaster = new THREE.Raycaster(); const intersectPoint = new THREE.Vector3(); const yPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); window.addEventListener('mousemove', e => { mouse.x = (e.clientX / window.innerWidth) * 2 - 1; mouse.y = -(e.clientY / window.innerHeight) * 2 + 1; }); const ssaoPass = new SSAOPass(scene, camera, window.innerWidth, window.innerHeight); ssaoPass.kernelRadius = 16; ssaoPass.minDistance = 0.01; ssaoPass.maxDistance = 0.2; ssaoPass.output = SSAOPass.OUTPUT.Default; //composer.addPass(ssaoPass); //composer.addPass(new OutputPass()); //const helper = new THREE.CameraHelper(light.shadow.camera); //scene.add(helper); renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; // === HEX TILE SETUP === const radius = 1; const hexWidth = 2 * radius; const hexHeight = Math.sqrt(3) * radius; const spacing = 0.1; const gridX = 100; const gridY = 100; const offsetX = -((gridX - 1) * (hexWidth * 0.75 + spacing)) / 2; const offsetZ = -((gridY - 1) * (hexHeight + spacing)) / 2; const hexGeo = new THREE.CylinderGeometry(radius, radius, 1, 6); hexGeo.rotateY(Math.PI / 6); const hexMat = new THREE.MeshStandardMaterial({ vertexColors: true // accepted but insufficient alone }); hexMat.onBeforeCompile = function ( shader ) { shader.vertexShader = shader.vertexShader.replace( '#include ', ` #include attribute vec3 instanceColor; varying vec3 vInstanceColor; ` ); shader.vertexShader = shader.vertexShader.replace( '#include ', ` #include vInstanceColor = instanceColor; ` ); shader.fragmentShader = shader.fragmentShader.replace( '#include ', ` #include varying vec3 vInstanceColor; ` ); shader.fragmentShader = shader.fragmentShader.replace( '#include ', ` diffuseColor.rgb *= vInstanceColor; ` ); }; const instCount = gridX * gridY; const instMesh = new THREE.InstancedMesh(hexGeo, hexMat, instCount); scene.add(instMesh); const colorArray = new Float32Array(instCount * 3); for (let i = 0; i < instCount; i++) { colorArray[i * 3 + 0] = 1.0; // R colorArray[i * 3 + 1] = 1.0; // G colorArray[i * 3 + 2] = 1.0; // B } const instanceColorAttr = new THREE.InstancedBufferAttribute(colorArray, 3); instMesh.geometry.setAttribute("instanceColor", instanceColorAttr); instMesh.castShadow = true; instMesh.receiveShadow = true; const dummy = new THREE.Object3D(); const tmpVec = new THREE.Vector3(); const hexPositions = []; let idx = 0; for (let y = 0; y < gridY; y++) { for (let x = 0; x < gridX; x++) { const px = x * (hexWidth * 0.75 + spacing) + offsetX; const pz = y * (hexHeight + spacing) + (x % 2) * (hexHeight / 2) + offsetZ; hexPositions.push({ x: px, z: pz }); dummy.position.set(px, 0, pz); dummy.scale.set(1, 1, 1); dummy.updateMatrix(); instMesh.setMatrixAt(idx++, dummy.matrix); } } instMesh.instanceMatrix.needsUpdate = true; const hexRadius = 1; const effectiveXGap = hexWidth * 0.75 + spacing; const effectiveZGap = hexHeight + spacing; const totalWidth = effectiveXGap * (gridX - 1) + hexWidth; const totalHeight = effectiveZGap * (gridY - 1) + hexHeight; // === GLOW PLANE === const glowPlane = new THREE.Mesh( new THREE.PlaneGeometry(totalWidth - (hexWidth/2), totalHeight - (hexWidth/2)), new THREE.MeshBasicMaterial({ color: new THREE.Color(10, 10, 10), toneMapped: false }) ); glowPlane.rotation.x = -Math.PI / 2; scene.add(glowPlane); let currentColorIndex = 0; const colorPalette = [ [1.0, 0.8, 0.0], // Amber [0.5, 1.0, 0.0], // Lime [0.0, 1.0, 0.0], // Green [0.0, 1.0, 0.5], // Spring Green [0.0, 1.0, 1.0], // Cyan [0.0, 0.5, 1.0], // Sky Blue [0.0, 0.0, 1.0], // Blue [0.5, 0.0, 1.0], // Purple [1.0, 0.0, 1.0], // Magenta [1.0, 0.0, 0.5] // Rose ]; const panelContainer = document.getElementById("colorPanelContainer"); const colorSwatches = []; function rgbToCssColor(rgb) { const [r, g, b] = rgb.map(c => Math.floor(c * 255)); return `rgb(${r}, ${g}, ${b})`; } function createColorPanels() { panelContainer.innerHTML = ""; colorSwatches.length = 0; colorPalette.forEach((color, index) => { const panel = document.createElement("div"); panel.className = "color-swatch"; panel.style.width = "24px"; panel.style.height = "24px"; panel.style.borderRadius = "4px"; panel.style.cursor = "pointer"; panel.style.backgroundColor = rgbToCssColor(color); panel.addEventListener("click", function () { currentColorIndex = index; updateColorSwatchSelection(); }); colorSwatches.push(panel); panelContainer.appendChild(panel); }); updateColorSwatchSelection(); } function updateColorSwatchSelection() { colorSwatches.forEach((panel, index) => { panel.style.border = index === currentColorIndex ? "3px solid white" : "1px solid #888"; }); } createColorPanels(); window.addEventListener("wheel", function (event) { if (event.deltaY > 0) { currentColorIndex = (currentColorIndex + 1) % colorPalette.length; } else { currentColorIndex = (currentColorIndex - 1 + colorPalette.length) % colorPalette.length; } updateColorSwatchSelection(); }); // === HEIGHTMAP === let heightData = null; let mapWidth = 0; let mapHeight = 0; let heightMapImage = new Image(); heightMapImage.crossOrigin = ''; heightMapImage.src = 'heightmap.png'; heightMapImage.onload = function () { const canvas = document.createElement('canvas'); canvas.width = heightMapImage.width; canvas.height = heightMapImage.height; mapWidth = canvas.width; mapHeight = canvas.height; const ctx = canvas.getContext('2d'); ctx.drawImage(heightMapImage, 0, 0); const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height).data; heightData = new Float32Array(canvas.width * canvas.height); for (let i = 0; i < canvas.width * canvas.height; i++) { heightData[i] = imgData[i * 4] / 255; } }; function sampleHeightFromMap(x, z) { if (!heightData) return 0; const nx = (x + 100) / 200; const nz = (z + 100) / 200; const ix = Math.floor(nx * mapWidth); const iz = Math.floor(nz * mapHeight); if (ix < 0 || ix >= mapWidth || iz < 0 || iz >= mapHeight) return 0; return heightData[iz * mapWidth + ix] * 8; } // === INTERACTION LOGIC === function updateMouseIntersection() { raycaster.setFromCamera(mouse, camera); raycaster.ray.intersectPlane(yPlane, intersectPoint); } const storedScales = new Float32Array(instCount).fill(1); let isLeftMouseDown = false; let isRightMouseDown = false; window.addEventListener( 'mousedown', function ( event ) { if ( event.button === 0 ) { isLeftMouseDown = true; } if ( event.button === 2 ) { isRightMouseDown = true; } } ); window.addEventListener( 'mouseup', function ( event ) { if ( event.button === 0 ) { isLeftMouseDown = false; } if ( event.button === 2 ) { isRightMouseDown = false; } } ); var elevate = true; /*document.getElementById("toggleMode").addEventListener("click", function () { elevate = !elevate; this.textContent = elevate ? "Return" : "Elevate"; });*/ var restore = false; document.getElementById("resetAll").addEventListener("click", function () { restore = true; }); controls.enableZoom = false; const currentColors = new Array(instCount).fill().map(() => [1, 1, 1]); // white start const targetColors = new Array(instCount).fill().map(() => [1, 1, 1]); // default: whi const isHovered = new Array(instCount).fill(false); let allColorsRestored = true; let pulseActive = false; let pulseStartTime = 0; const pulseTotalTime = 2.4; // seconds document.getElementById("pulseButton").addEventListener("click", function () { pulseActive = true; pulseStartTime = performance.now() / 1000; }); function smoothPulse( t ) { if (t < 0 || t > 1) return 0; return Math.pow( 2 * t - 1, 4 ); // fast center, smooth edges } function updateInstancedHeights() { if (!heightData) return; let allRestored = true; let allColorsRestored = true; for (let i = 0; i < instCount; i++) { const { x, z } = hexPositions[i]; tmpVec.set(x, 0, z); const now = performance.now() / 1000; const pulseTime = now - pulseStartTime; let pulseOffset = 0; if (pulseActive) { const now = performance.now() / 1000; const pulseTime = now - pulseStartTime; const distToCenter = tmpVec.length(); const waveDelay = distToCenter * 0.05; const localTime = pulseTime - waveDelay; const amplitude = 1; if (localTime >= 0 && localTime <= pulseTotalTime) { const normalized = localTime / pulseTotalTime; const wave = smoothPulse( normalized ); pulseOffset = wave * amplitude; //pulseOffset = wave * 1.5; // adjust amplitude } } var baseY = storedScales[i]; var targetY = baseY + pulseOffset; if (pulseTime > pulseTotalTime + 2.0) { pulseActive = false; } const d = tmpVec.distanceTo(intersectPoint); const isHovering = d < 3.5; // Elevation logic //let targetY = storedScales[i]; if (elevate) { if (isHovering) { const sampled = sampleHeightFromMap(x, z); const scaled = 1 + sampled * 3; if (scaled > targetY) { targetY = scaled; } } } else { if (isHovering) { targetY = 1; } } if (restore) { targetY = 1; if (Math.abs(storedScales[i] - targetY) > 1.01) { allRestored = false; } } // Interpolate elevation storedScales[i] += (targetY - storedScales[i]) * 0.1; // Apply matrix dummy.position.set(x, 0, z); dummy.scale.set(1, storedScales[i], 1); dummy.updateMatrix(); instMesh.setMatrixAt(i, dummy.matrix); } const instanceColors = instMesh.geometry.attributes.instanceColor; const [ r, g, b ] = colorPalette[ currentColorIndex ]; for (let i = 0; i < instCount; i++) { const { x, z } = hexPositions[i]; tmpVec.set(x, 0, z); const d = tmpVec.distanceTo(intersectPoint); // Mark hover state if (d < 3.5 && !restore) { targetColors[i][0] = r; targetColors[i][1] = g; targetColors[i][2] = b; isHovered[i] = true; } else { isHovered[i] = false; } if (restore) { targetColors[i][0] = 1; targetColors[i][1] = 1; targetColors[i][2] = 1; isHovered[i] = false; } // Only update current color if hovered this frame if (isHovered[i] || restore) { for (let j = 0; j < 3; j++) { currentColors[i][j] += (targetColors[i][j] - currentColors[i][j]) * 0.05; } } const [ cr, cg, cb ] = currentColors[i]; if (restore) { if (Math.abs(cr - 1) > 0.01 || Math.abs(cg - 1) > 0.01 || Math.abs(cb - 1) > 0.01) { allColorsRestored = false; } } instanceColors.setXYZ(i, currentColors[i][0], currentColors[i][1], currentColors[i][2]); } instanceColors.needsUpdate = true; instanceColors.needsUpdate = true; instanceColors.needsUpdate = true; instMesh.instanceMatrix.needsUpdate = true; if (restore && allRestored && allColorsRestored) { restore = false; } } // === ANIMATION LOOP === function animate() { requestAnimationFrame(animate); updateMouseIntersection(); updateInstancedHeights(); controls.update(); camera.layers.set(1); composer.render(); camera.layers.set(0); renderer.clearDepth(); composer.render(); } animate(); window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); composer.setSize(window.innerWidth, window.innerHeight); });