Skip to content

Instantly share code, notes, and snippets.

@Hijikinoheya
Created October 8, 2021 05:22
Show Gist options
  • Save Hijikinoheya/52eae933abc2cb19f4531c3b1ebd971c to your computer and use it in GitHub Desktop.
Save Hijikinoheya/52eae933abc2cb19f4531c3b1ebd971c to your computer and use it in GitHub Desktop.
Image to Cityscape
<aside class="aside-open">
<form action="">
<button id="aside-btn" class="aside-toggle aside-toggle-open" type="button">
<span class="sr">Toggle Panel</span>
</button>
<label for="img_upload">Image</label>
<div class="upload-btn">
<input id="img_upload" name="img_upload" type="file" accept="image/*">
<button for="img_upload" type="button" tabindex="-1" title="Upload">
<svg viewBox="0 0 512 512" width="24" height="24" xmlns="http://www.w3.org/2000/svg">
<path d="m182.461 155.48 49.539-49.539v262.059a24 24 0 0 0 48 0v-262.059l49.539 49.539a24 24 0 1 0 33.941-33.941l-90.509-90.51a24 24 0 0 0 -33.942 0l-90.509 90.51a24 24 0 1 0 33.941 33.941z"/>
<path d="m464 232a24 24 0 0 0 -24 24v184h-368v-184a24 24 0 0 0 -48 0v192a40 40 0 0 0 40 40h384a40 40 0 0 0 40-40v-192a24 24 0 0 0 -24-24z"/>
</svg>
</button>
<input id="img_name" name="img_name" type="text" placeholder="No file selected" disabled>
</div>
<button id="reset-btn" type="button">Reset</button>
</form>
</aside>
window.addEventListener("DOMContentLoaded",app);
function app() {
let asideBtn = document.getElementById("aside-btn"),
resetBtn = document.getElementById("reset-btn"),
imgUpload = document.getElementsByName("img_upload")[0],
imgName = document.getElementsByName("img_name")[0],
canvas = document.createElement("canvas"),
c = canvas.getContext("2d"),
img = null,
scene,
camera,
camControls,
renderer,
textureLoader = new THREE.TextureLoader(),
city,
dust,
// adjustable
skyColor = 0x8fa6af,
terrainColor = 0xe8bfa9,
chunkSize = 64,
gridSize = 7,
roadWidth = 8,
minBldgHt = 16,
maxBldgHt = 48,
bldgSize = 12,
bldgFragHt = 2,
bldgsPerChunkSide = 3,
bldgDisplaceFactor = 0.25,
dustParticleSpeed = 0.2,
dustParticlesPerChunk = 12,
sunAngle = 30,
worldHeight = 64,
worldSize = 1600,
// technical
chunkSizeHalf = chunkSize / 2,
gridSizeEven = gridSize % 2 == 0,
gridSizeHalf = gridSize / 2,
bldgCellsPerSide = bldgsPerChunkSide * gridSize,
roadsSide = chunkSize * gridSize + roadWidth,
roadsSideHalf = roadsSide / 2,
dustParticles = dustParticlesPerChunk * gridSize ** 2,
// functions
adjustWindow = () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth,window.innerHeight);
},
clearCity = () => {
img = null;
if (scene) {
let children = city.children;
while (children.length) {
let child = children[0];
// kill windows of a building
if (child.name == "Civilian Structure") {
let gchild = child.children[0];
gchild.geometry.dispose();
gchild.material.dispose();
child.remove(gchild);
}
child.geometry.dispose();
child.material.dispose();
city.remove(child);
}
}
},
getDistance = (x1,y1,x2,y2) => {
let raw = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2),
rounded = Math.round(raw * 1e3) / 1e3;
return rounded;
},
getLightness = (R,G,B) => {
let r = R / 255,
g = G / 255,
b = B / 255,
cmin = Math.min(r,g,b),
cmax = Math.max(r,g,b),
light = (cmax + cmin) / 2;
return light;
},
generateCity = imgData => {
let bldgSizeHalf = bldgSize / 2,
bldgNegEdge = -bldgSizeHalf - 0.01,
bldgPosEdge = bldgSizeHalf + 0.01,
chunkDiv = chunkSize / bldgsPerChunkSide,
chunkDivHalf = chunkDiv / 2,
bldgTrans = -(chunkSize * gridSizeHalf) + chunkDivHalf,
pixelIndex = 0,
sidewalks = scene.children.filter(child => child.name == "Sidewalk");
for (let z = 0; z < bldgCellsPerSide; ++z) {
for (let x = 0; x < bldgCellsPerSide; ++x) {
if (
bldgsPerChunkSide % 2 == 0 ||
z % bldgsPerChunkSide != Math.floor(bldgsPerChunkSide / 2) ||
x % bldgsPerChunkSide != Math.floor(bldgsPerChunkSide / 2)
) {
// building
let bldgHeight,
bldgHeightHalf,
bldgGeo,
bldgMat,
bldg,
alpha = imgData ? imgData[pixelIndex + 3] : 0;
if (imgData && alpha > 0.1) {
let red = imgData[pixelIndex],
green = imgData[pixelIndex + 1],
blue = imgData[pixelIndex + 2],
lightness = getLightness(red,green,blue);
bldgHeight = minBldgHt + Math.round(lightness * (maxBldgHt - minBldgHt) / bldgFragHt) * bldgFragHt;
bldgHeightHalf = bldgHeight / 2;
bldgGeo = new THREE.BoxBufferGeometry(bldgSize,bldgHeight,bldgSize);
bldgMat = new THREE.MeshPhongMaterial({
color: `rgb(${red},${green},${blue})`
});
bldg = new THREE.Mesh(bldgGeo,bldgMat);
} else {
let randHue = randomHue();
bldgHeight = minBldgHt + Math.round(Math.random() * (maxBldgHt - minBldgHt) / bldgFragHt) * bldgFragHt;
bldgHeightHalf = bldgHeight / 2;
bldgGeo = new THREE.BoxBufferGeometry(bldgSize,bldgHeight,bldgSize);
bldgMat = new THREE.MeshPhongMaterial({
color: `hsl(${randHue},50%,45%)`
});
bldg = new THREE.Mesh(bldgGeo,bldgMat);
}
bldgMat.shininess = 90;
bldg.name = "Civilian Structure";
bldg.castShadow = true;
bldg.receiveShadow = true;
bldg.position.set(
x * chunkDiv + bldgTrans,
bldgHeight / 2 + 1,
z * chunkDiv + bldgTrans
);
// displacement towards the center of the sidewalk
let sidewalkIndex = Math.floor(x / bldgsPerChunkSide) + (gridSize * Math.floor(z / bldgsPerChunkSide)),
sidewalk = sidewalks[sidewalkIndex];
if (sidewalk) {
let sidewalkCoords = sidewalks[sidewalkIndex].position,
sX = sidewalkCoords.x,
bX = bldg.position.x,
sZ = sidewalkCoords.z,
bZ = bldg.position.z,
distFromSWCenter = getDistance(sX,sZ,bX,bZ),
newDist = distFromSWCenter * (1 - bldgDisplaceFactor),
distX = Math.abs(sX - bX),
distZ = Math.abs(sZ - bZ),
distAngle = Math.atan(distZ / distX);
if (bX > sX && bZ <= sZ)
distAngle += Math.PI / 2;
else if (bX <= sX && bZ <= sZ)
distAngle += Math.PI;
else if (bX <= sX && bZ > sZ)
distAngle += Math.PI * 1.5;
bldg.position.x = sX + (newDist * Math.sin(distAngle));
bldg.position.z = sZ + (newDist * Math.cos(distAngle));
}
city.add(bldg);
// windows
let windowGeo = new THREE.BufferGeometry(),
windowVertArr = [];
for (let wy = 0; wy < bldgHeight; wy += 2) {
for (let wx = 0; wx < 12; wx += 2) {
let leftWinEdge = (-bldgSizeHalf + 0.5) + wx,
rightWinEdge = (-bldgSizeHalf + 1.5) + wx,
bottomWinEdge = (-bldgHeightHalf + 0.5) + wy,
topWinEdge = (-bldgHeightHalf + 1.5) + wy;
windowVertArr.push(
// north
rightWinEdge,bottomWinEdge,bldgNegEdge,
leftWinEdge,bottomWinEdge,bldgNegEdge,
leftWinEdge,topWinEdge,bldgNegEdge,
leftWinEdge,topWinEdge,bldgNegEdge,
rightWinEdge,topWinEdge,bldgNegEdge,
rightWinEdge,bottomWinEdge,bldgNegEdge,
// east
bldgPosEdge,bottomWinEdge,rightWinEdge,
bldgPosEdge,bottomWinEdge,leftWinEdge,
bldgPosEdge,topWinEdge,leftWinEdge,
bldgPosEdge,topWinEdge,leftWinEdge,
bldgPosEdge,topWinEdge,rightWinEdge,
bldgPosEdge,bottomWinEdge,rightWinEdge,
// south
leftWinEdge,bottomWinEdge,bldgPosEdge,
rightWinEdge,bottomWinEdge,bldgPosEdge,
rightWinEdge,topWinEdge,bldgPosEdge,
rightWinEdge,topWinEdge,bldgPosEdge,
leftWinEdge,topWinEdge,bldgPosEdge,
leftWinEdge,bottomWinEdge,bldgPosEdge,
// west
bldgNegEdge,bottomWinEdge,leftWinEdge,
bldgNegEdge,bottomWinEdge,rightWinEdge,
bldgNegEdge,topWinEdge,rightWinEdge,
bldgNegEdge,topWinEdge,rightWinEdge,
bldgNegEdge,topWinEdge,leftWinEdge,
bldgNegEdge,bottomWinEdge,leftWinEdge
);
}
}
let windowVerts = new Float32Array(windowVertArr),
windowMat = new THREE.MeshBasicMaterial({
color: 0x17181c
});
windows = new THREE.Mesh(windowGeo,windowMat);
windowGeo.setAttribute("position",new THREE.BufferAttribute(windowVerts,3));
bldg.add(windows);
}
pixelIndex += 4;
}
}
},
handleImgUpload = e => {
return new Promise((resolve,reject) => {
if (imgUpload) {
let target = !e ? imgUpload : e.target;
if (target.files.length) {
let reader = new FileReader();
reader.onload = e2 => {
img = new Image();
img.src = e2.target.result;
img.onload = () => {
resolve();
};
img.onerror = () => {
img = null;
reject("The image was nullified due to corruption or a non-image upload.");
};
if (imgName)
imgName.placeholder = target.files[0].name;
};
reader.readAsDataURL(target.files[0]);
}
} else {
reject("The file input is missing.");
}
});
},
imgUploadValid = () => {
if (imgUpload) {
let files = imgUpload.files,
fileIsThere = files.length > 0,
isImage = files[0].type.match("image.*"),
valid = fileIsThere && isImage;
return valid;
} else {
return false;
}
},
init = () => {
// setup
scene = new THREE.Scene();
scene.fog = new THREE.Fog(skyColor,512,640);
// renderer
renderer = new THREE.WebGLRenderer({
logarithmicDepthBuffer: true
});
renderer.setClearColor(skyColor);
renderer.setSize(window.innerWidth,window.innerHeight);
renderer.shadowMap.enabled = true;
// camera
camera = new THREE.PerspectiveCamera(60,window.innerWidth / window.innerHeight,0.1,1000);
camera.position.set(160,160,160);
camera.lookAt(scene.position);
camControls = new THREE.OrbitControls(camera,renderer.domElement);
camControls.enablePan = false;
camControls.minDistance = 8;
camControls.maxDistance = 512;
camControls.minPolarAngle = -Math.PI / 2;
camControls.maxPolarAngle = Math.PI / 2;
// lighting
let daylight = new THREE.AmbientLight(0xfbfbb6,1);
daylight.name = "Daylight";
scene.add(daylight);
let sun = new THREE.PointLight(0xffffff,2,worldSize,2);
sun.name = "Sun";
sun.position.set(
worldSize / 2 * Math.sin(sunAngle * Math.PI / 180),
worldSize / 2 * Math.cos(sunAngle * Math.PI / 180),
0
);
sun.castShadow = true;
scene.add(sun);
// terrain
let terrainGeo = new THREE.PlaneBufferGeometry(worldSize,worldSize),
terrainMat = new THREE.MeshStandardMaterial({
color: terrainColor
}),
terrain = new THREE.Mesh(terrainGeo,terrainMat);
terrain.name = "Terrain";
terrain.rotation.x = -0.5 * Math.PI;
terrain.position.y = -0.01;
terrain.receiveShadow = true;
scene.add(terrain);
// roads
let roadGeo = new THREE.PlaneBufferGeometry(roadsSide,roadsSide),
roadMat = new THREE.MeshPhongMaterial({
color: 0x2e3138
}),
road = new THREE.Mesh(roadGeo,roadMat);
roadMat.shininess = 35;
road.name = "Road";
road.rotation.x = -0.5 * Math.PI;
road.receiveShadow = true;
scene.add(road);
// sidewalks
let sidewalkGeo = new THREE.BoxBufferGeometry(
chunkSize - roadWidth,
1,
chunkSize - roadWidth
),
sidewalkMat = new THREE.MeshPhongMaterial({
color: 0x5c6270
}),
sidewalk = new THREE.Mesh(sidewalkGeo,sidewalkMat);
sidewalk.name = "Sidewalk";
sidewalk.receiveShadow = true;
let zStart = -Math.floor(gridSizeHalf),
zEnd = -zStart - (gridSizeEven ? 1 : 0),
xStart = zStart,
xEnd = zEnd;
for (let z = zStart; z <= zEnd; ++z) {
for (let x = xStart; x <= xEnd; ++x) {
let sidewalkUnit = sidewalk.clone();
sidewalkUnit.position.set(
chunkSize * x,
0.5,
chunkSize * z
);
if (gridSizeEven) {
sidewalkUnit.position.x += chunkSizeHalf;
sidewalkUnit.position.z += chunkSizeHalf;
}
scene.add(sidewalkUnit);
}
}
// build the city
city = new THREE.Object3D();
city.name = "City";
scene.add(city);
generateCity();
// dust particles
let dustGeo = new THREE.BufferGeometry(),
dustVertArr = [];
for (let p = 0; p < dustParticles; ++p) {
dustVertArr.push(
Math.round(roadsSide * Math.random() - roadsSide / 2),
Math.round(Math.random() * worldHeight),
Math.round(roadsSide * Math.random() - roadsSide / 2)
);
}
let dustVerts = new Float32Array(dustVertArr),
dustMat = new THREE.PointsMaterial({
map: textureLoader.load("https://i.ibb.co/mqQrvZ1/dust.png"),
color: 0xffff00,
size: 2,
transparent: true
});
dustGeo.setAttribute("position",new THREE.BufferAttribute(dustVerts,3));
dust = new THREE.Points(dustGeo,dustMat);
dust.name = "Dust Particles";
scene.add(dust);
// render
let body = document.body;
body.insertBefore(renderer.domElement,body.firstChild);
renderScene();
// deal with preserved input
if (imgUpload && imgUpload.value != "")
renderPromise();
},
moveDust = () => {
let posArr = dust.geometry.attributes.position.array,
dirs = 8,
newPosArr = posArr.map((a,i) => {
let dim = i % 3,
dir = i % dirs,
angle = 360 * (dir / dirs) * Math.PI / 180;
if (dim == 0)
a += dustParticleSpeed * Math.sin(angle);
else if (dim == 2)
a += dustParticleSpeed * Math.cos(angle);
if (dim == 0 || dim == 2) {
a += dustParticleSpeed;
if (a > roadsSideHalf)
a -= roadsSide;
else if (a < -roadsSideHalf)
a += roadsSide;
} else if (dim == 1) {
a += dustParticleSpeed;
if (a >= worldHeight)
a = 0;
}
return a;
});
let newDustVerts = new Float32Array(newPosArr);
dust.geometry.setAttribute("position",new THREE.BufferAttribute(newDustVerts,3));
dust.geometry.verticesNeedUpdate = true;
},
randomHue = () => {
let roundHueTo = 30,
r = Math.floor(Math.random() * 360 / roundHueTo) * roundHueTo;
return r;
},
renderPromise = e => {
handleImgUpload(e).then(() => {
if (imgUploadValid()) {
updateCanvas();
updateImg();
}
}).catch(msg => {
console.log(msg);
});
},
renderScene = () => {
moveDust();
renderer.render(scene,camera);
requestAnimationFrame(renderScene);
},
resetCity = () => {
if (imgName)
imgName.placeholder = "No file selected";
clearCity();
generateCity();
},
toggleAside = e => {
let aside = document.querySelector("aside");
if (aside) {
let openClass = "aside-open";
if (e.keyCode == 27)
aside.classList.remove(openClass);
else if (!e.keyCode)
aside.classList.toggle(openClass);
}
},
updateCanvas = () => {
// restrict image size, keep it proportional
let imgWidth = img.width,
imgHeight = img.height;
if (imgWidth >= imgHeight) {
if (imgWidth >= bldgCellsPerSide) {
imgWidth = bldgCellsPerSide;
imgHeight = imgWidth * (img.height / img.width);
}
} else {
if (imgHeight >= bldgCellsPerSide) {
imgHeight = bldgCellsPerSide;
imgWidth = imgHeight * (img.width / img.height);
}
}
// update canvas
c.clearRect(0,0,bldgCellsPerSide,bldgCellsPerSide);
let imgX = bldgCellsPerSide / 2 - imgWidth / 2,
imgY = bldgCellsPerSide / 2 - imgHeight / 2;
c.drawImage(img,imgX,imgY,imgWidth,imgHeight);
},
updateImg = () => {
let imgData = c.getImageData(0,0,bldgCellsPerSide,bldgCellsPerSide),
data = imgData.data;
clearCity();
generateCity(data);
};
init();
if (asideBtn) {
asideBtn.addEventListener("click",toggleAside);
window.addEventListener("keydown",toggleAside);
}
if (resetBtn)
resetBtn.addEventListener("click",resetCity);
if (imgUpload)
imgUpload.addEventListener("change",renderPromise);
window.addEventListener("resize",adjustWindow);
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r127/three.min.js"></script>
<script src="https://codepen.io/jkantner/pen/abBdPYe"></script>
* {
border: 0;
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
font-size: calc(16px + (24 - 16) * (100vw - 320px) / (2560 - 320));
--bg: #737a8c;
--buttonBg: #2762f3;
--buttonHoverBg: #0c48db;
--formBg: #fff;
--inputBorder: #abafba;
--inputBg: #fff;
--inputDisableBg: #e3e4e8;
--pColor: #17181c;
}
aside {
background: var(--formBg);
box-shadow: 0 0 0.25em hsla(223,10%,10%,0.5);
border-radius: 0.375em 0.375em 0 0;
position: fixed;
bottom: 0;
left: 1.25em;
max-width: 17.5em;
width: calc(100% - 2.5em);
transform: translateY(100%);
transition: transform 0.3s ease-in-out;
}
body, button, input {
color: var(--pColor);
font: 1em/1.5 "Hind", -apple-system, sans-serif;
}
body, .upload-btn {
overflow: hidden;
}
button:hover, button:focus, .upload-btn input[type=file]:hover + button, .upload-btn input[type=file]:focus + button {
background: var(--buttonHoverBg);
}
button, .upload-btn input[type=file], .upload-btn input[type=file]::-webkit-file-upload-button {
cursor: pointer;
}
button, input {
display: block;
width: 100%;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
button {
background: var(--buttonBg);
border-radius: 0.375em;
color: #fff;
margin-bottom: 1.5em;
padding: 0.75em 1em;
transition: background 0.1s linear;
}
button:focus {
outline: 0;
}
form {
padding: 1.5em 1.5em 0 1.5em;
position: relative;
}
input {
background: var(--inputBg);
border-radius: 0.25em;
box-shadow: 0 0 0 1px var(--inputBorder) inset;
padding: 0.75em;
}
input:disabled {
background: var(--inputDisableBg);
cursor: not-allowed;
text-overflow: ellipsis;
}
label {
display: inline-block;
font-weight: bold;
}
.aside-toggle, .aside-toggle:hover, .aside-toggle:focus {
background-color: var(--formBg);
}
.aside-toggle {
border-radius: 0.375em 0.375em 0 0;
margin: 0;
padding: 0.25em 1em;
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
transition: none;
width: 4.5em;
height: 1.5em;
}
.aside-toggle:before {
border-left: 0.5em solid transparent;
border-right: 0.5em solid transparent;
border-bottom: 0.5em solid;
color: var(--pColor);
content: "";
display: block;
position: absolute;
top: 33%;
left: calc(50% - 0.5em);
width: 0;
height: 0;
transition: color 0.1s linear;
}
.aside-toggle:hover:before, .aside-toggle:focus:before {
color: var(--buttonBg);
}
.aside-open {
transform: translateY(0%);
}
.aside-open .aside-toggle:before {
border-bottom: 0;
border-top: 0.5em solid;
}
.sr {
clip: rect(1px,1px,1px,1px);
overflow: hidden;
position: absolute;
}
.upload-btn, .upload-btn input[type=text], .upload-btn input[type=file] + button {
margin-bottom: 0.75em;
}
.upload-btn {
display: flex;
justify-content: space-between;
position: relative;
}
.upload-btn input[type=text] {
width: calc(62.5% - 0.375em);
}
.upload-btn input[type=file], .upload-btn input[type=file] + button {
width: calc(37.5% - 0.375em);
}
.upload-btn input[type=file] {
position: absolute;
opacity: 0;
top: 0;
left: 0;
height: 3em;
}
.upload-btn input[type=file] + button svg {
display: block;
margin: auto;
width: 1.5em;
height: 1.5em;
}
.upload-btn input[type=file] + button path {
fill: #fff;
}
/* Dark theme */
@media (prefers-color-scheme: dark) {
:root {
--formBg: #17181c;
--inputBg: #2e3138;
--inputDisableBg: #2e3138;
--inputBorder: #5c6270;
--pColor: #e3e4e8;
}
}
<link href="https://fonts.googleapis.com/css2?family=Hind:wght@400;700&amp;display=swap" rel="stylesheet" />
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment