<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge, chrome=1">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="robots" content="noindex">
<title>io-fx12</title>
<meta name="author" content="iowen.cn">
<style>
body{background:black;margin:0;padding:0;overflow:hidden}
canvas{background:transparent;background-image:linear-gradient(black 20%,#101 30%,#211 40%,#070702 52%,#000 90%,#000 100%);background-repeat:no-repeat;display:block;margin:0 auto;width:100%;;height:300px}
#vignette{background-image:linear-gradient(right,black 0%,transparent 10%,transparent 90%,black 100%);position:absolute;top:0;left:50%;width:100%;height:300px;-webkit-transform:translateX(-50%);transform:translateX(-50%);z-index:50}
</style>
</head>
<body>
<div id="vignette"></div>
<script> console.clear()
// OVERENGINEERED UNOPTIMIZED CANVAS BULLSH*T
// BUT IT'S OKAY SINCE IT'S BLADE RUNNER INNIT
// Some stuff left unoptimized / verbose to show the work.
// TODO:
// - optimize render loop, avoid overdraws, etc
// - smoothly fade rows in on the horizon
// Constants. Change at own risk
const CANVAS_WIDTH = screenWidth()
const CANVAS_HEIGHT = 300
const FRAME_TIME = 1000 / 16
const LIGHT_ROWS = 20
const LIGHT_ROW_DEPTH = 2
const LIGHT_SPACING = 0.6
const LIGHT_SIZE = 0.1
const LIGHT_SCATTER = 0.4
const BUILDING_ROWS = 38
const BUILDING_ROW_DEPTH = 1
const BUILDING_ROW_WIDTH = 60
const BUILDING_MIN_HEIGHT = 1.5
const BUILDING_MAX_HEIGHT = 3
const STACK_HEIGHT = 9
const STACK_THRESHOLD = 0.87
const STACK_LIGHT_CHANCE = 0.95
const STACK_LIGHT_SIZE = 0.13
const FADE_GRAY_VALUE = 25
const FADE_OFFSET = 0.35
// screen width
function screenWidth() {
_width = document.body.offsetWidth;
if(_width<600)
_width=600
if(_width>1200&&_width<1800)
_width=1200
if(_width>1800)
_width=1800
return _width;
}
// Virtual camera. Used in perspective calculations
const CAMERA = {
x: 0,
y: 10,
z: 0,
fov: 170,
dist: 30,
zSpeed: 0.005,
}
// Virtual vanishing point XY. Used in perspective calculations
const VP_OFS = {
x: 0.5,
y: 0.27,
}
// Global hoisted vars for rendering contexts and timers
let c, ctx, output_c, output_ctx
let _t, _dt, _ft
// Seedable random number generator.
// Not particularly well-distributed, but fine for this case.
// Allows us to emit the same set of random numbers on every frame
// so we can consistently re-render the scene.
const RNG = {
seed: 1,
random() {
const x = Math.sin(RNG.seed++) * 10000
return x - (x << 0)
},
randomInRange(min, max) {
return ((RNG.random() * (max - min + 1)) << 0) + min
}
}
// Module to get a random colour from a predefined list.
// Uses the seedable RNG
const Palette = (() => {
const PAL = ['black', '#111', '#113', 'white', 'sliver', '#f88', 'orange', 'oldlace', '#569']
const lastIndex = PAL.length - 1
function getRandomFromPalette() {
return PAL[RNG.randomInRange(0, lastIndex)]
}
return {
getRandom: getRandomFromPalette
}
})()
function ceil(n) {
var f = (n << 0),
f = f == n ? f: f + 1
return f
}
// Update method of main loop
function update() {
// Update our global timestamp (used in rendering)
_t = Date.now() * 0.001
// Move the camera slowly 'forward'
CAMERA.z += CAMERA.zSpeed
}
// Draw a frame of the scene.
// Uses the current timestamp and the seeded RNG to render a
// pseudorandom cityscape with lights and buildings.
// We always generate and draw a set amount of city in front of
// the camera, so it appears to be endless as we 'fly over' it.
//
// 1. Clear the whole scene
// 2. Render random rows of lights
// 3. Render random rows of buildings
// 4. Blit scene to onscreen canvas
let _$ = {
vPointX: 0,
vPointY: 0,
rowScreenX: 0,
MAX_LIGHTS: 0,
closestLightRow: 0,
rowZ: 0,
rowRelativeZ: 0,
scalingFactor: 0,
rowScreenWidth: 0,
rowScreenHeight: 0,
rowScreenY: 0,
rowScreenLightSpacing: 0,
rowLightCount: 0,
lightSize: 0,
lightHalfSize: 0,
lightScreenX: 0,
lightScreenY: 0,
closestBuildingRow: 0,
rowBuildingCount: 0,
rowBuildingScreenWidth: 0,
rowShade: 0,
rowStyleString: '',
lightData: [],
isStack: false,
buildingHeight: 0,
buildingScreenHeight: 0,
buildingScreenX: 0,
buildingScreenY: 0,
lightSize: 0,
lightHalfSize: 0,
lightColor: 0,
}
function render() {
// Calculate the pixel XY of the vanishing point
// (could be done on init, but useful if we ever want to
// dynamically move the camera)
_$.vPointX = c.width * VP_OFS.x >> 0
_$.vPointY = c.height * VP_OFS.y >> 0
// If we wanted to, we could give each row an X offset
// and include it in perspective calculations,
// but we just use the centre alignment for each one here.
_$.rowScreenX = CAMERA.x + _$.vPointX
// 1. Clear the whole scene...
// (canvases are transparent so that the CSS 'sky' gradient can be seen)
ctx.clearRect(0, 0, c.width, c.height)
output_ctx.clearRect(0, 0, output_c.width, output_c.height)
// 2. Render random rows of lights...
// Calculate the closest row to the camera so we
// can render the required number of rows into the distance
_$.closestLightRow = Math.floor(CAMERA.z / LIGHT_ROW_DEPTH)
// Draw each row of lights
for (let i = 0; i < LIGHT_ROWS; i++) {
// Calculate this row's base Z position
// and Z relative to camera
_$.rowZ = (_$.closestLightRow * LIGHT_ROW_DEPTH) + (LIGHT_ROW_DEPTH * i)
_$.rowRelativeZ = _$.rowZ - CAMERA.z
// Don't draw the row if it's behind the camera,
// or beyond the camera's draw distance
if (_$.rowRelativeZ <= 0 || _$.rowRelativeZ > CAMERA.dist) {
continue
}
// Get the perspective scaling factor and pixel Y position for this row
_$.scalingFactor = CAMERA.fov / _$.rowRelativeZ
_$.rowScreenY = CAMERA.y * _$.scalingFactor + _$.vPointY
// Don't draw the row if it's off-canvas
if (_$.rowScreenY > c.height) {
continue
}
// Calculate the spacing and number of lights we need to render for this row
_$.rowScreenLightSpacing = LIGHT_SPACING * _$.scalingFactor
_$.rowLightCount = c.width / _$.rowScreenLightSpacing
// Seed the RNG in a way that gets us decent distribution
// for the random lights
RNG.seed = _$.rowZ * 0.573
// Render the random lights for this row
for (let j = 0; j < _$.rowLightCount; j++) {
// Randomize light size, with perspective
_$.lightSize = RNG.random() * (LIGHT_SIZE * _$.scalingFactor)
_$.lightHalfSize = _$.lightSize * 0.5
// Randomly offset the XY of the light, with perspective
_$.lightScreenX = (j * _$.rowScreenLightSpacing) + (RNG.random() * LIGHT_SCATTER * _$.scalingFactor) - _$.lightHalfSize
_$.lightScreenY = (_$.rowScreenY + (RNG.random() * LIGHT_SCATTER) * _$.scalingFactor) - _$.lightHalfSize
// Don't render if the light is offscreen
if (_$.lightScreenX < 0 || _$.lightScreenX > c.width || _$.lightScreenY > c.height) {
// HACK: we still need to call the RNG the same number of times
// for every row to ensure consistency between frames. If we didn't
// do this, the lights would jump all over the place near the edges
// of the screen.
Palette.getRandom()
continue
}
// Pick a random colour for this light
ctx.fillStyle = Palette.getRandom()
// Render the light twice, mirrored either side of the centre vanishing point.
// Saves us having to do perspective offset calculation for every light,
// and won't be noitceable when we overlay the city buildings.
ctx.fillRect((_$.rowScreenX + _$.lightScreenX), _$.lightScreenY, _$.lightSize, _$.lightSize)
ctx.fillRect((_$.rowScreenX - _$.lightScreenX), _$.lightScreenY, _$.lightSize, _$.lightSize)
}
}
// 3. Render random rows of buildings...
// Calculate the closest row to the camera so we
// can render the required number of rows into the distance
_$.closestBuildingRow = Math.floor(CAMERA.z / BUILDING_ROW_DEPTH)
// Draw each row of buildings
for (let i = BUILDING_ROWS; i > 0; i--) {
// Calculate this row's base Z position
// and Z relative to camera
_$.rowZ = (_$.closestBuildingRow * BUILDING_ROW_DEPTH) + (BUILDING_ROW_DEPTH * i)
_$.rowRelativeZ = _$.rowZ - CAMERA.z
// Don't draw the row if it's behind the camera,
// or beyond the camera's draw distance
if (_$.rowRelativeZ <= 0 || _$.rowRelativeZ > CAMERA.dist) {
continue
}
// Get the perspective scaling factor and pixel Y position for this row
_$.scalingFactor = CAMERA.fov / _$.rowRelativeZ
// Calculate the perspective-scaled position and base size of our row.
// Offset the XY so that the row's 'origin' is at centre bottom (i.e. ground-up)
_$.rowScreenWidth = BUILDING_ROW_WIDTH * _$.scalingFactor;
_$.rowScreenHeight = BUILDING_MAX_HEIGHT * _$.scalingFactor;
_$.rowScreenX = CAMERA.x * _$.scalingFactor + _$.vPointX - (_$.rowScreenWidth * 0.5)
_$.rowScreenY = CAMERA.y * _$.scalingFactor + _$.vPointY - _$.rowScreenHeight
// Seed the RNG to keep rendering consistent for this row
RNG.seed = _$.rowZ
// Calculate a random number of buildings for this row
// and get their screen width
_$.rowBuildingCount = RNG.randomInRange(20, 70)
_$.rowBuildingScreenWidth = _$.rowScreenWidth / _$.rowBuildingCount
// Calculate the shade we want the buildings in this row to be.
// The tint is darker nearer the camera, giving a sort of crude distance fog
// near the horizon.
_$.rowShade = Math.round(FADE_GRAY_VALUE * (_$.rowRelativeZ / (CAMERA.dist) - FADE_OFFSET))
_$.rowStyleString = 'rgb(' + _$.rowShade + ',' + _$.rowShade + ',' + _$.rowShade + ')'
// Calclate and render each building
_$.lightData.length = 0
ctx.fillStyle = _$.rowStyleString
for (let j = 0; j < _$.rowBuildingCount; j++) {
// Buildings have a certain chance to become a 'stack' i.e. way taller than
// everything else. We calculate a random ranged height for the building,
// and if it exceeds a threshold, it gets turned into a stack.
_$.isStack = false
_$.buildingHeight = Math.max(BUILDING_MIN_HEIGHT, RNG.random() * BUILDING_MAX_HEIGHT)
if (_$.buildingHeight > (BUILDING_MAX_HEIGHT * STACK_THRESHOLD)) {
_$.isStack = true
// Stacks have 40% height variance
_$.buildingHeight = (STACK_HEIGHT * 0.6 + (RNG.random() * 0.4))
}
// Calculate the pixel size and position of this building, adjusted for perspective
_$.buildingScreenHeight = _$.buildingHeight * _$.scalingFactor
_$.buildingScreenX = _$.rowScreenX + (j * _$.rowBuildingScreenWidth)
_$.buildingScreenY = _$.rowScreenY + _$.rowScreenHeight - _$.buildingScreenHeight
// Draw the building on screen
ctx.fillRect(_$.buildingScreenX, _$.buildingScreenY, Math.ceil(_$.rowBuildingScreenWidth), _$.buildingScreenHeight)
// Seed the RNG for consistency when calculating stack lights (if needed)
RNG.seed = _$.buildingHeight + j
// Stacks have a chance to get lights on their top corners.
// Generate and store light data so we can render it on top of the buildings
if (_$.isStack && RNG.random() < STACK_LIGHT_CHANCE) {
// Get random light size and color.
// Slightly higher chance of red vs white lights
_$.lightSize = RNG.random() * (STACK_LIGHT_SIZE * _$.scalingFactor)
_$.lightColor = (RNG.random() > 0.6) ? 'white': 'red'
// Save light info for rendering after we do all the buildings
// (helps minimixe changes to ctx.fillStyle)
_$.lightData.push(_$.buildingScreenX)
_$.lightData.push(_$.buildingScreenY)
_$.lightData.push(_$.lightSize)
_$.lightData.push(_$.lightColor)
}
}
// Draw any lights on stacks that need them in this row
for (let j = 0; j < _$.lightData.length; j += 4) {
_$.buildingScreenX = _$.lightData[j]
_$.buildingScreenY = _$.lightData[j + 1]
_$.lightSize = _$.lightData[j + 2]
_$.lightHalfSize = _$.lightSize * 0.5
_$.lightColor = _$.lightData[j + 3]
// Draw lights centred at the top left and right corners of the stack
ctx.fillStyle = _$.lightColor
ctx.fillRect(_$.buildingScreenX - _$.lightHalfSize, _$.buildingScreenY - _$.lightHalfSize, _$.lightSize, _$.lightSize)
ctx.fillRect(_$.buildingScreenX + _$.rowBuildingScreenWidth - _$.lightHalfSize, _$.buildingScreenY - _$.lightHalfSize, _$.lightSize, _$.lightSize)
}
}
// 4. Blit scene to onscreen canvas.
// Now that we've built up the scene in-memory, we just render the image to
// our canvas in the DOM.
output_ctx.drawImage(c, 0, 0)
}
// Main loop.
// Maintains a consistent update rate, but draws the screen as often
// as the browser will allow.
function frame() {
requestAnimationFrame(frame)
_ft = Date.now()
update()
if (_ft - _dt > FRAME_TIME) {
render()
_dt = _ft
}
}
// Let's go!
function start() {
// Init frame timers (see frame())
_dt = _ft = Date.now()
// Create two canvases - one for in-memory compositing,
// and another to go in the DOM for our final render.
// Make them the same size as each other.
c = document.createElement('canvas')
ctx = c.getContext('2d')
output_c = document.createElement('canvas')
output_ctx = output_c.getContext('2d')
output_c.width = c.width = CANVAS_WIDTH
output_c.height = c.height = CANVAS_HEIGHT
document.body.appendChild(output_c)
// Start the main loop.
frame()
}
start()
</script>
</body>
</html>