|
<!DOCTYPE html> |
|
<html lang="en"> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
<title>Three.js Dynamic Simulated City</title> |
|
<script src="https://cdn.tailwindcss.com"></script> |
|
<style> |
|
body { margin: 0; overflow: hidden; background-color: #000000; color: #e2e8f0; font-family: 'Inter', sans-serif; } |
|
canvas { display: block; } |
|
#infoPanel { |
|
position: absolute; |
|
top: 20px; |
|
left: 20px; |
|
background-color: rgba(0,0,0,0.75); |
|
padding: 15px; |
|
border-radius: 8px; |
|
color: white; |
|
font-size: 0.85em; |
|
max-width: 320px; |
|
box-shadow: 0 4px 12px rgba(0,0,0,0.5); |
|
max-height: 90vh; |
|
overflow-y: auto; |
|
} |
|
#infoPanel h2 { |
|
margin-top: 0; |
|
font-size: 1.1em; |
|
border-bottom: 1px solid #4a5568; |
|
padding-bottom: 5px; |
|
margin-bottom: 10px; |
|
} |
|
#infoPanel p, #infoPanel ul { |
|
margin-bottom: 8px; |
|
line-height: 1.5; |
|
} |
|
#infoPanel ul { |
|
list-style: disc; |
|
padding-left: 20px; |
|
} |
|
#loadingScreen { |
|
position: fixed; |
|
top: 0; |
|
left: 0; |
|
width: 100%; |
|
height: 100%; |
|
background-color: #111827; |
|
display: flex; |
|
flex-direction: column; |
|
justify-content: center; |
|
align-items: center; |
|
z-index: 9999; |
|
color: white; |
|
font-size: 1.5em; |
|
} |
|
.spinner { |
|
border: 4px solid rgba(255, 255, 255, 0.3); |
|
border-radius: 50%; |
|
border-top: 4px solid #fff; |
|
width: 40px; |
|
height: 40px; |
|
animation: spin 1s linear infinite; |
|
margin-bottom: 20px; |
|
} |
|
@keyframes spin { |
|
0% { transform: rotate(0deg); } |
|
100% { transform: rotate(360deg); } |
|
} |
|
</style> |
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet"> |
|
</head> |
|
<body> |
|
<div id="loadingScreen"> |
|
<div class="spinner"></div> |
|
Loading Dynamic City... |
|
</div> |
|
<div id="infoPanel"> |
|
<h2>Dynamic Simulated City</h2> |
|
<p>Observe the day/night cycle, moving entities, and dynamic building windows. This visualization demonstrates how a more complex simulation could be represented.</p> |
|
<p><strong>Features Added:</strong></p> |
|
<ul> |
|
<li>Moving cars and people (simple paths).</li> |
|
<li>Birds flying in the sky.</li> |
|
<li>Building windows with lights turning on/off.</li> |
|
<li>Sun and Moon with day/night cycle affecting lighting and sky.</li> |
|
</ul> |
|
<p><strong>Conceptual Integration Points:</strong> (As before, imagine these influencing the dynamics)</p> |
|
<ul> |
|
<li><strong>AI Agents:</strong> Could control traffic flow, pedestrian density, "power grid" for window lights, or trigger city-wide events based on (simulated) external data.</li> |
|
<li><strong>Wasm/Fractals:</strong> Could define more organic city growth, traffic patterns, or even complex behaviors for the simulated entities.</li> |
|
</ul> |
|
<p><em>Use mouse to orbit, scroll to zoom, right-click to pan.</em></p> |
|
</div> |
|
<canvas id="cityCanvas"></canvas> |
|
|
|
<script type="importmap"> |
|
{ |
|
"imports": { |
|
"three": "https://cdn.jsdelivr.net/npm/three@0.164.1/build/three.module.js", |
|
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.164.1/examples/jsm/" |
|
} |
|
} |
|
</script> |
|
|
|
<script type="module"> |
|
import * as THREE from 'three'; |
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; |
|
|
|
let scene, camera, renderer, controls; |
|
const buildings = []; |
|
const vehicles = []; |
|
const pedestrians = []; |
|
const birds = []; |
|
|
|
const citySize = 20; |
|
const buildingSpacing = 2.0; |
|
const roadWidth = 0.5; |
|
const buildingMaxHeight = 8; |
|
const buildingMinHeight = 1; |
|
|
|
let sunLight, moonLight, ambientLight; |
|
const skyRadius = citySize * buildingSpacing * 1.5; |
|
|
|
const dayClearColor = new THREE.Color(0x87CEEB); |
|
const nightClearColor = new THREE.Color(0x000020); |
|
const dayFogColor = new THREE.Color(0x87CEEB); |
|
const nightFogColor = new THREE.Color(0x000010); |
|
|
|
|
|
function init() { |
|
const canvas = document.getElementById('cityCanvas'); |
|
renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: true }); |
|
renderer.setSize(window.innerWidth, window.innerHeight); |
|
renderer.setPixelRatio(window.devicePixelRatio); |
|
renderer.shadowMap.enabled = true; |
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap; |
|
renderer.toneMapping = THREE.ACESFilmicToneMapping; |
|
renderer.toneMappingExposure = 0.8; |
|
|
|
|
|
scene = new THREE.Scene(); |
|
|
|
|
|
camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, skyRadius * 2.5); |
|
camera.position.set(citySize * 0.7, citySize * 0.6, citySize * 0.7); |
|
|
|
controls = new OrbitControls(camera, renderer.domElement); |
|
controls.enableDamping = true; |
|
controls.dampingFactor = 0.05; |
|
controls.screenSpacePanning = false; |
|
controls.minDistance = 3; |
|
controls.maxDistance = citySize * 2.5; |
|
controls.maxPolarAngle = Math.PI / 2 - 0.01; |
|
|
|
|
|
ambientLight = new THREE.AmbientLight(0xffffff, 0.1); |
|
scene.add(ambientLight); |
|
|
|
sunLight = new THREE.DirectionalLight(0xffffee, 0); |
|
sunLight.castShadow = true; |
|
sunLight.shadow.mapSize.width = 2048; |
|
sunLight.shadow.mapSize.height = 2048; |
|
sunLight.shadow.camera.near = 0.5; |
|
sunLight.shadow.camera.far = skyRadius * 0.8; |
|
sunLight.shadow.camera.left = -citySize * 1.5; |
|
sunLight.shadow.camera.right = citySize * 1.5; |
|
sunLight.shadow.camera.top = citySize * 1.5; |
|
sunLight.shadow.camera.bottom = -citySize * 1.5; |
|
sunLight.shadow.bias = -0.0005; |
|
scene.add(sunLight); |
|
|
|
|
|
|
|
|
|
moonLight = new THREE.DirectionalLight(0x7788cc, 0); |
|
moonLight.castShadow = true; |
|
moonLight.shadow.mapSize.width = 1024; |
|
moonLight.shadow.mapSize.height = 1024; |
|
moonLight.shadow.camera.near = 0.5; |
|
moonLight.shadow.camera.far = skyRadius * 0.8; |
|
moonLight.shadow.bias = -0.0005; |
|
scene.add(moonLight); |
|
|
|
|
|
const groundGeometry = new THREE.PlaneGeometry(citySize * buildingSpacing * 1.2, citySize * buildingSpacing * 1.2); |
|
const groundMaterial = new THREE.MeshStandardMaterial({ |
|
color: 0x445544, |
|
roughness: 0.9, |
|
metalness: 0.1 |
|
}); |
|
const ground = new THREE.Mesh(groundGeometry, groundMaterial); |
|
ground.rotation.x = -Math.PI / 2; |
|
ground.receiveShadow = true; |
|
scene.add(ground); |
|
|
|
|
|
generateCity(); |
|
createVehicles(30); |
|
createPedestrians(50); |
|
createBirds(20); |
|
|
|
document.getElementById('loadingScreen').style.display = 'none'; |
|
window.addEventListener('resize', onWindowResize, false); |
|
animate(); |
|
} |
|
|
|
|
|
function generateCity() { |
|
const buildingBaseGeometry = new THREE.BoxGeometry(1, 1, 1); |
|
|
|
for (let i = 0; i < citySize; i++) { |
|
for (let j = 0; j < citySize; j++) { |
|
if (Math.random() > 0.25) { |
|
const buildingHeight = THREE.MathUtils.randFloat(buildingMinHeight, buildingMaxHeight); |
|
const buildingWidth = THREE.MathUtils.randFloat(0.7, buildingSpacing - roadWidth * 0.8); |
|
const buildingDepth = THREE.MathUtils.randFloat(0.7, buildingSpacing - roadWidth * 0.8); |
|
|
|
const buildingMaterial = new THREE.MeshStandardMaterial({ |
|
color: new THREE.Color().setHSL(Math.random() * 0.1 + 0.55, 0.1, Math.random() * 0.2 + 0.3), |
|
roughness: THREE.MathUtils.randFloat(0.6, 0.9), |
|
metalness: THREE.MathUtils.randFloat(0.0, 0.2), |
|
}); |
|
|
|
const building = new THREE.Mesh(buildingBaseGeometry, buildingMaterial); |
|
building.scale.set(buildingWidth, buildingHeight, buildingDepth); |
|
building.position.set( |
|
(i - citySize / 2 + 0.5) * buildingSpacing, |
|
buildingHeight / 2, |
|
(j - citySize / 2 + 0.5) * buildingSpacing |
|
); |
|
building.castShadow = true; |
|
building.receiveShadow = true; |
|
scene.add(building); |
|
building.userData.windows = []; |
|
|
|
|
|
const windowSize = 0.15; |
|
const windowSpacing = 0.25; |
|
const windowDepthOffset = 0.01; |
|
|
|
|
|
for (let bh = windowSpacing; bh < buildingHeight - windowSpacing; bh += windowSpacing * 2) { |
|
for (let bw = -buildingWidth / 2 + windowSpacing; bw < buildingWidth / 2 - windowSpacing / 2; bw += windowSpacing * 1.5) { |
|
if (Math.random() < 0.8) { |
|
const windowMat = new THREE.MeshStandardMaterial({ |
|
color: 0x111122, |
|
emissive: 0x000000, |
|
emissiveIntensity: 0, |
|
transparent: true, |
|
opacity: 0.7 |
|
}); |
|
const windowGeo = new THREE.PlaneGeometry(windowSize, windowSize); |
|
const win = new THREE.Mesh(windowGeo, windowMat); |
|
win.position.set(bw / buildingWidth, (bh - buildingHeight/2) / buildingHeight , 0.5 + windowDepthOffset/buildingDepth); |
|
building.add(win); |
|
building.userData.windows.push(win); |
|
} |
|
} |
|
} |
|
|
|
for (let bh = windowSpacing; bh < buildingHeight - windowSpacing; bh += windowSpacing * 2) { |
|
for (let bd = -buildingDepth / 2 + windowSpacing; bd < buildingDepth / 2 - windowSpacing / 2; bd += windowSpacing * 1.5) { |
|
if (Math.random() < 0.8) { |
|
const windowMat = new THREE.MeshStandardMaterial({ |
|
color: 0x111122, emissive: 0x000000, emissiveIntensity: 0, transparent: true, opacity: 0.7 |
|
}); |
|
const windowGeo = new THREE.PlaneGeometry(windowSize, windowSize); |
|
const win = new THREE.Mesh(windowGeo, windowMat); |
|
win.position.set(0.5 + windowDepthOffset/buildingWidth, (bh - buildingHeight/2) / buildingHeight, bd / buildingDepth); |
|
win.rotation.y = Math.PI / 2; |
|
building.add(win); |
|
building.userData.windows.push(win); |
|
} |
|
} |
|
} |
|
|
|
for (let bh = windowSpacing; bh < buildingHeight - windowSpacing; bh += windowSpacing * 2) { |
|
for (let bw = -buildingWidth / 2 + windowSpacing; bw < buildingWidth / 2 - windowSpacing / 2; bw += windowSpacing * 1.5) { |
|
if (Math.random() < 0.8) { |
|
const windowMat = new THREE.MeshStandardMaterial({ |
|
color: 0x111122, emissive: 0x000000, emissiveIntensity: 0, transparent: true, opacity: 0.7 |
|
}); |
|
const windowGeo = new THREE.PlaneGeometry(windowSize, windowSize); |
|
const win = new THREE.Mesh(windowGeo, windowMat); |
|
win.position.set(bw / buildingWidth, (bh - buildingHeight/2) / buildingHeight , -0.5 - windowDepthOffset/buildingDepth); |
|
win.rotation.y = Math.PI; |
|
building.add(win); |
|
building.userData.windows.push(win); |
|
} |
|
} |
|
} |
|
for (let bh = windowSpacing; bh < buildingHeight - windowSpacing; bh += windowSpacing * 2) { |
|
for (let bd = -buildingDepth / 2 + windowSpacing; bd < buildingDepth / 2 - windowSpacing / 2; bd += windowSpacing * 1.5) { |
|
if (Math.random() < 0.8) { |
|
const windowMat = new THREE.MeshStandardMaterial({ |
|
color: 0x111122, emissive: 0x000000, emissiveIntensity: 0, transparent: true, opacity: 0.7 |
|
}); |
|
const windowGeo = new THREE.PlaneGeometry(windowSize, windowSize); |
|
const win = new THREE.Mesh(windowGeo, windowMat); |
|
win.position.set(-0.5 - windowDepthOffset/buildingWidth, (bh - buildingHeight/2) / buildingHeight, bd / buildingDepth); |
|
win.rotation.y = -Math.PI / 2; |
|
building.add(win); |
|
building.userData.windows.push(win); |
|
} |
|
} |
|
} |
|
|
|
|
|
buildings.push(building); |
|
} |
|
} |
|
} |
|
} |
|
|
|
|
|
function createVehicles(count) { |
|
const carGeo = new THREE.BoxGeometry(0.6, 0.25, 0.3); |
|
const carMat = new THREE.MeshStandardMaterial({ color: 0xaa0000, roughness: 0.3, metalness: 0.5 }); |
|
for (let i = 0; i < count; i++) { |
|
const vehicle = new THREE.Mesh(carGeo, carMat.clone()); |
|
vehicle.material.color.setHSL(Math.random(), 0.7, 0.5); |
|
vehicle.castShadow = true; |
|
|
|
const onXAxis = Math.random() > 0.5; |
|
const roadLine = Math.floor(Math.random() * citySize) - citySize / 2 + 0.5; |
|
vehicle.position.set( |
|
onXAxis ? (Math.random() - 0.5) * citySize * buildingSpacing : roadLine * buildingSpacing + (Math.random() > 0.5 ? roadWidth : -roadWidth), |
|
0.125, |
|
onXAxis ? roadLine * buildingSpacing + (Math.random() > 0.5 ? roadWidth : -roadWidth) : (Math.random() - 0.5) * citySize * buildingSpacing |
|
); |
|
vehicle.userData.speed = THREE.MathUtils.randFloat(0.02, 0.05) * (Math.random() > 0.5 ? 1 : -1); |
|
vehicle.userData.axis = onXAxis ? 'x' : 'z'; |
|
if ((vehicle.userData.axis === 'x' && vehicle.userData.speed < 0) || (vehicle.userData.axis === 'z' && vehicle.userData.speed > 0)) { |
|
vehicle.rotation.y = Math.PI / 2; |
|
} |
|
|
|
|
|
scene.add(vehicle); |
|
vehicles.push(vehicle); |
|
} |
|
} |
|
|
|
function createPedestrians(count) { |
|
const pedGeo = new THREE.CylinderGeometry(0.05, 0.05, 0.3, 8); |
|
const pedMat = new THREE.MeshStandardMaterial({ color: 0x00aa00, roughness: 0.8, metalness: 0.1 }); |
|
for (let i = 0; i < count; i++) { |
|
const pedestrian = new THREE.Mesh(pedGeo, pedMat.clone()); |
|
pedestrian.material.color.setHSL(Math.random(), 0.6, 0.6); |
|
pedestrian.castShadow = true; |
|
|
|
const buildingIndex = Math.floor(Math.random() * buildings.length); |
|
const targetBuilding = buildings[buildingIndex]; |
|
if (!targetBuilding) continue; |
|
|
|
const side = Math.floor(Math.random() * 4); |
|
let offsetX = 0, offsetZ = 0; |
|
const sidewalkOffset = targetBuilding.scale.x / 2 + 0.15; |
|
|
|
if (side === 0) { offsetZ = sidewalkOffset; offsetX = (Math.random()-0.5) * targetBuilding.scale.x; } |
|
else if (side === 1) { offsetZ = -sidewalkOffset; offsetX = (Math.random()-0.5) * targetBuilding.scale.x; } |
|
else if (side === 2) { offsetX = sidewalkOffset; offsetZ = (Math.random()-0.5) * targetBuilding.scale.z; } |
|
else { offsetX = -sidewalkOffset; offsetZ = (Math.random()-0.5) * targetBuilding.scale.z; } |
|
|
|
pedestrian.position.set( |
|
targetBuilding.position.x + offsetX, |
|
0.15, |
|
targetBuilding.position.z + offsetZ |
|
); |
|
pedestrian.userData.speed = THREE.MathUtils.randFloat(0.005, 0.01); |
|
pedestrian.userData.direction = new THREE.Vector3(Math.random()-0.5, 0, Math.random()-0.5).normalize(); |
|
scene.add(pedestrian); |
|
pedestrians.push(pedestrian); |
|
} |
|
} |
|
|
|
function createBirds(count) { |
|
const birdGeo = new THREE.SphereGeometry(0.1, 8, 6); |
|
const birdMat = new THREE.MeshStandardMaterial({ color: 0x555555, roughness: 0.5 }); |
|
for (let i = 0; i < count; i++) { |
|
const bird = new THREE.Mesh(birdGeo, birdMat.clone()); |
|
bird.material.color.setHex(Math.random() * 0xffffff); |
|
bird.position.set( |
|
(Math.random() - 0.5) * citySize * buildingSpacing * 0.8, |
|
THREE.MathUtils.randFloat(buildingMaxHeight + 2, buildingMaxHeight + 10), |
|
(Math.random() - 0.5) * citySize * buildingSpacing * 0.8 |
|
); |
|
bird.userData.speed = THREE.MathUtils.randFloat(0.02, 0.06); |
|
bird.userData.phase = Math.random() * Math.PI * 2; |
|
bird.userData.amplitudeY = THREE.MathUtils.randFloat(0.5, 2); |
|
bird.userData.radius = THREE.MathUtils.randFloat(citySize * 0.2, citySize * 0.5); |
|
bird.userData.angle = Math.random() * Math.PI * 2; |
|
bird.userData.angleSpeed = THREE.MathUtils.randFloat(0.001, 0.005) * (Math.random() > 0.5 ? 1: -1); |
|
|
|
scene.add(bird); |
|
birds.push(bird); |
|
} |
|
} |
|
|
|
|
|
|
|
let lastWindowUpdateTime = 0; |
|
const windowUpdateInterval = 200; |
|
|
|
function animate(time) { |
|
requestAnimationFrame(animate); |
|
const currentTime = time || 0; |
|
const delta = currentTime - (lastWindowUpdateTime || 0); |
|
|
|
|
|
const timeOfDay = (currentTime * 0.00002) % 1; |
|
const sunAngle = timeOfDay * Math.PI * 2; |
|
|
|
|
|
sunLight.position.set( |
|
Math.cos(sunAngle) * skyRadius, |
|
Math.sin(sunAngle) * skyRadius * 0.7, |
|
Math.sin(sunAngle - Math.PI / 4) * skyRadius |
|
); |
|
sunLight.intensity = Math.max(0, Math.sin(sunAngle)) * 1.8; |
|
sunLight.visible = sunLight.intensity > 0.01; |
|
|
|
|
|
|
|
const moonAngle = sunAngle + Math.PI; |
|
moonLight.position.set( |
|
Math.cos(moonAngle) * skyRadius * 0.9, |
|
Math.sin(moonAngle) * skyRadius * 0.6, |
|
Math.sin(moonAngle - Math.PI / 4) * skyRadius * 0.9 |
|
); |
|
moonLight.intensity = Math.max(0, Math.sin(moonAngle)) * 0.4; |
|
moonLight.visible = moonLight.intensity > 0.01; |
|
|
|
|
|
const dayFactor = Math.pow(Math.max(0, Math.sin(sunAngle)), 0.7); |
|
ambientLight.intensity = dayFactor * 0.5 + 0.1; |
|
|
|
scene.background = nightClearColor.clone().lerp(dayClearColor, dayFactor); |
|
if (scene.fog) { |
|
scene.fog.color = nightFogColor.clone().lerp(dayFogColor, dayFactor); |
|
scene.fog.near = skyRadius * 0.2 * (1 - dayFactor * 0.5); |
|
scene.fog.far = skyRadius * (1 - dayFactor * 0.3); |
|
} else { |
|
scene.fog = new THREE.Fog(scene.background, skyRadius * 0.2, skyRadius); |
|
} |
|
|
|
|
|
|
|
if (delta > windowUpdateInterval) { |
|
lastWindowUpdateTime = currentTime; |
|
buildings.forEach(building => { |
|
building.userData.windows.forEach(win => { |
|
if (Math.random() < 0.05) { |
|
const isNight = dayFactor < 0.3; |
|
const lightOnProb = isNight ? 0.6 : 0.15; |
|
|
|
if (Math.random() < lightOnProb) { |
|
win.material.emissive.setHex(0xffffaa); |
|
win.material.emissiveIntensity = THREE.MathUtils.randFloat(0.5, 1.2); |
|
win.material.opacity = 0.9; |
|
} else { |
|
win.material.emissive.setHex(0x000000); |
|
win.material.emissiveIntensity = 0; |
|
win.material.opacity = 0.6; |
|
} |
|
} |
|
}); |
|
}); |
|
} |
|
|
|
|
|
const cityBoundary = citySize * buildingSpacing / 2; |
|
vehicles.forEach(v => { |
|
if (v.userData.axis === 'x') { |
|
v.position.x += v.userData.speed; |
|
if (v.position.x > cityBoundary && v.userData.speed > 0) v.position.x = -cityBoundary; |
|
if (v.position.x < -cityBoundary && v.userData.speed < 0) v.position.x = cityBoundary; |
|
} else { |
|
v.position.z += v.userData.speed; |
|
if (v.position.z > cityBoundary && v.userData.speed > 0) v.position.z = -cityBoundary; |
|
if (v.position.z < -cityBoundary && v.userData.speed < 0) v.position.z = cityBoundary; |
|
} |
|
}); |
|
|
|
|
|
pedestrians.forEach(p => { |
|
p.position.addScaledVector(p.userData.direction, p.userData.speed); |
|
if (Math.random() < 0.01) { |
|
p.userData.direction.set(Math.random()-0.5, 0, Math.random()-0.5).normalize(); |
|
} |
|
|
|
p.position.x = THREE.MathUtils.clamp(p.position.x, -cityBoundary, cityBoundary); |
|
p.position.z = THREE.MathUtils.clamp(p.position.z, -cityBoundary, cityBoundary); |
|
}); |
|
|
|
|
|
birds.forEach(b => { |
|
b.userData.angle += b.userData.angleSpeed; |
|
b.position.x = Math.cos(b.userData.angle) * b.userData.radius; |
|
b.position.z = Math.sin(b.userData.angle) * b.userData.radius; |
|
b.position.y = (buildingMaxHeight + 5) + Math.sin(currentTime * 0.001 * b.userData.speed + b.userData.phase) * b.userData.amplitudeY; |
|
}); |
|
|
|
|
|
controls.update(); |
|
renderer.render(scene, camera); |
|
} |
|
|
|
|
|
function onWindowResize() { |
|
camera.aspect = window.innerWidth / window.innerHeight; |
|
camera.updateProjectionMatrix(); |
|
renderer.setSize(window.innerWidth, window.innerHeight); |
|
} |
|
|
|
|
|
init(); |
|
</script> |
|
</body> |
|
</html> |
|
|