548 lines
13 KiB
JavaScript
548 lines
13 KiB
JavaScript
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 <common>',
|
|
`
|
|
#include <common>
|
|
attribute vec3 instanceColor;
|
|
varying vec3 vInstanceColor;
|
|
`
|
|
);
|
|
|
|
shader.vertexShader = shader.vertexShader.replace(
|
|
'#include <begin_vertex>',
|
|
`
|
|
#include <begin_vertex>
|
|
vInstanceColor = instanceColor;
|
|
`
|
|
);
|
|
|
|
shader.fragmentShader = shader.fragmentShader.replace(
|
|
'#include <common>',
|
|
`
|
|
#include <common>
|
|
varying vec3 vInstanceColor;
|
|
`
|
|
);
|
|
|
|
shader.fragmentShader = shader.fragmentShader.replace(
|
|
'#include <color_fragment>',
|
|
`
|
|
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);
|
|
});
|