First commit

This commit is contained in:
2025-12-25 10:16:42 +01:00
commit 09ba6fc5d4
3 changed files with 666 additions and 0 deletions

BIN
heightmap.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

547
tiled_ground.js Normal file
View File

@@ -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 <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);
});

119
tiles.html Normal file
View File

@@ -0,0 +1,119 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Three.js Texture Editor</title>
<style>
html, body {
margin: 0;
padding: 0;
overflow: hidden;
width: 100vw;
height: 100vh;
}
body {
position: relative;
font-family: "Segoe UI", sans-serif;
}
canvas {
display: block;
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 0;
}
.ui-overlay {
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
z-index: 10;
}
#colorPanelContainer {
position: absolute;
top: 42px;
left: 12px;
display: flex;
gap: 6px;
padding: 6px;
background: rgba(0, 0, 0, 0.4);
border-radius: 6px;
pointer-events: auto;
}
.color-swatch {
width: 24px;
height: 24px;
border: 2px solid white;
cursor: pointer;
border-radius: 4px;
}
.color-swatch.selected {
outline: 2px solid yellow;
}
.button-group {
position: absolute;
top: 12px;
right: 12px;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: auto;
}
button {
padding: 6px 14px;
background: linear-gradient(to bottom, #f0f0f0, #dcdcdc);
border: 1px solid #999;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
}
button:hover {
background: linear-gradient(to bottom, #ffffff, #e0e0e0);
}
</style>
</head>
<body>
<div class="ui-overlay">
<div id="colorHint" style="
position: absolute;
top: 16px;
left: 10px;
color: white;
font-family: sans-serif;
font-size: 12px;
background: rgba(0,0,0,0.6);
padding: 4px 6px;
border-radius: 4px;
pointer-events: none;
z-index: 10;
">
Scroll to switch colors
</div>
<div id="colorPanelContainer"></div>
<div class="button-group">
<button id="resetAll">Reset All</button>
<button id="pulseButton">Pulse</button>
</div>
</div>
<script type="module" src="./tiled_ground.js"></script>
</body>
</html>