commit 09ba6fc5d47680c3c5a2a98bfb9630239be4079d Author: kaj dijkstra Date: Thu Dec 25 10:16:42 2025 +0100 First commit diff --git a/heightmap.png b/heightmap.png new file mode 100644 index 0000000..4c244fc Binary files /dev/null and b/heightmap.png differ diff --git a/tiled_ground.js b/tiled_ground.js new file mode 100644 index 0000000..8dfe2f5 --- /dev/null +++ b/tiled_ground.js @@ -0,0 +1,547 @@ +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); +}); diff --git a/tiles.html b/tiles.html new file mode 100644 index 0000000..103a83f --- /dev/null +++ b/tiles.html @@ -0,0 +1,119 @@ + + + + + Three.js Texture Editor + + + +
+
+ Scroll to switch colors +
+ +
+ +
+ + + + +
+
+ + + +