You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

187 lines
8.2 KiB
TypeScript

/**
* hsl - creates hsl color string from h, s and l values
* @param h: number - hue
* @param s: number - saturation
* @param l: number - lightness
*/
function hsl(h: number, s: number, l: number): string {
return `hsl(${h}, ${s}%, ${l}%)`
}
/**
* render godrays
* @param ctx: CanvasRenderingContext2D - where to draw
* @param cx: number - x-axis center of the "sun"
* @param cy: number - y-axis center of the "sun"
* @param sunY: number - the position (height) of the "sun" in the sky
* @param r: number [44] - the radius of the "sun"
* @returns emissionStrength: number - emission intensity blends over the mountains
*/
function renderGodrays(ctx: CanvasRenderingContext2D, cx: number, cy: number, sunY: number, r = 44) {
const w = ctx.canvas.width
const h = ctx.canvas.height
const emissionGradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, r)
ctx.fillStyle = emissionGradient
// Now we addColorStops. This needs to be a dark gradient because our
// godrays effect will basically overlay it on top of itself many many times,
// so anything lighter will result in lots of white.
// If you're not space-bound you can add another stop or two, maybe fade out to black,
// but this actually looks good enough.
// a black "gradient" means no emission, so we fade to black as transition to night or day
let emissionStrength = 1.0
if (sunY > -30) emissionStrength -= Math.max((30 + sunY) / 5, 0.0)
else if (sunY < -60) emissionStrength += Math.min(1 + (60 + sunY) / 5, 0.0)
emissionGradient.addColorStop(.1, hsl(30, 50, 3.1 * emissionStrength)) // pixels in radius 0 to 4.4 (44 * .1).
emissionGradient.addColorStop(.2, hsl(12, 71, 1.4 * emissionStrength)) // pixels in radius 0 to 4.4 (44 * .1).
// Now paint the gradient all over our godrays canvas.
ctx.fillRect(0, 0, w, h)
// And set the fillstyle to black, we'll use it to paint our occlusion (mountains).
ctx.fillStyle = '#000'
return emissionStrength
}
/**
* calculate mountain height
* Mountains are made by summing up sine waves with varying frequencies and amplitudes
* The frequencies are prime, to avoid extra repetitions
*/
function calcMountainHeight(pos: number, roughness: number, frequencies = [1721, 947, 547, 233, 73, 31, 7]) {
return frequencies.reduce((height, freq) => height * roughness - Math.cos(freq * pos), 0)
}
/**
* render mountains
* @param ctx: CanvasRenderingContext2D - where to draw
* @param grCtx: CanvasRenderingContext2D - for drawing mountain shadows on the godray canvas
* @param frame: number - current frame (position on the x axis)
* @param sunY: number - position (height) of the "sun" in the sky
* @param layers: number - amount of mountain layers for parallax effect
* @param emissionStrength: number - intensity of the godrays
*/
function renderMountains(ctx: CanvasRenderingContext2D, grCtx: CanvasRenderingContext2D, frame: number, sunY: number, layers: number, emissionStrength: number) {
const w = ctx.canvas.width
const h = ctx.canvas.height
const grDiv = w / grCtx.canvas.width
for (let i = 0; i < layers; i++) {
// Set the main canvas fillStyle to a shade of green-brown with variable lightness
// depending on sunY and depth
ctx.fillStyle = sunY > -60
? hsl(5, 23, 33*emissionStrength - i*6*emissionStrength)
: hsl(220 - i*40, 23, 33-i*6)
for (let x = w; x--;) {
// Ok, I don't really remember the details here, basically the (frame+frame*i*i) makes the
// near mountains move faster than the far ones. We divide by large numbers because our
// mountains repeat at position 1/7*Math.PI*2 or something like that...
const pos = (frame * 2 * i**2) / 1000 + x / 2000
// Make further mountains more jagged, adds a bit of realism and also makes the godrays
// look nicer.
const roughness = i / 19 - .5
// 128 is the middle, i * 25 moves the nearer mountains lower on the screen.
let y = 128 + i * 25 + calcMountainHeight(pos, roughness) * 45
// Paint a 1px-wide rectangle from the mountain's top to below the bottom of the canvas.
ctx.fillRect(x, y, 1, h)
// Paint the same thing in black on the godrays emission canvas, which is 1/4 the size,
// and move it one pixel down (otherwise there can be a tiny underlit space between the
// mountains and the sky).
grCtx.fillRect(x/grDiv, y/grDiv+1, 1, h)
}
}
}
/**
* render sky
* @param ctx: CanvasRenderingContext2D - where to draw
* @param sunY: number - the position (height) of the "sun" in the sky
*/
function renderSky(ctx: CanvasRenderingContext2D, sunY: number) {
const w = ctx.canvas.width
const h = ctx.canvas.height
const skyGradient = ctx.createLinearGradient(0, 0, 0, h)
const skyHue = 360 + sunY // hue from blue to red, depending on the suns position
const skySaturation = 100 + sunY // less saturation at day so that the red fades away
const skyLightness = Math.min(sunY * -1 - 10, 55) // darker at night
const skyHSLTop = `hsl(220, 70%, ${skyLightness}%)`
const skyHSLBottom = `hsl(${skyHue}, ${skySaturation}%, ${skyLightness}%)`
skyGradient.addColorStop(0, skyHSLTop)
skyGradient.addColorStop(.7, skyHSLBottom)
ctx.fillStyle = skyGradient
ctx.fillRect(0, 0, w, h)
}
/**
* useBackground
* @param canvasEl: HTMLCanvasElement - the canvas to draw the background on.
* @param w: number - the (pixel) width of the canvas. The element itself can have a different width.
* @param h: number - the (pixel) height of the canvas. The element itself can have a different height.
* @param rayQuality: number [8] - The quality of the sunrays (divides the resolution, so higher value means lower quality)
* @param mountainLayers: number [4] - How many layers of mountains are used for parallax effect?
*/
export default function useBackground (canvasEl: HTMLCanvasElement, w: number, h: number, rayQuality = 8, mountainLayers = 4) {
canvasEl.width = w
canvasEl.height = h
const grW = w / rayQuality
const grH = h / rayQuality
const ctx = canvasEl.getContext('2d')
if (ctx === null) return // like, how old is your browser?
const grCanvasEl = document.createElement('canvas')
const grCtx = grCanvasEl.getContext('2d')
if (grCtx === null) return // like, how old is your browser?
grCanvasEl.width = grW
grCanvasEl.height = grH
const sunCenterX = grCanvasEl.width / 2
const sunCenterY = grCanvasEl.height / 2
/**
* draw one frame of the background
* @param frame: number - the position on the x axis, to calculate the paralax background
* @param sunY: number - the position (height) of the sun in the sky
*/
return function drawFrame (frame: number, sunY: number) {
console.log('drawing frame', frame, sunY)
const emissionStrength = renderGodrays(grCtx, sunCenterX, sunCenterY, sunY)
renderSky(ctx, sunY)
renderMountains(ctx, grCtx, frame, sunY, mountainLayers, emissionStrength)
// The godrays are generated by adding up RGB values, gCt is the bane of all js golfers -
// globalCompositeOperation. Set it to 'lighter' on both canvases.
ctx.globalCompositeOperation = grCtx.globalCompositeOperation = 'lighter'
// NOW - let's light this m**f** up! We'll make several passes over our emission canvas,
// each time adding an enlarged copy of it to itself so at the first pass we get 2 copies, then 4,
// then 8, then 16 etc... We square our scale factor at each iteration.
for (let scaleFactor = 1.07; scaleFactor < 5; scaleFactor *= scaleFactor) {
// The x, y, width and height arguments for drawImage keep the light source in the same
// spot on the enlarged copy. It basically boils down to multiplying a 2D matrix by itself.
// There's probably a better way to do this, but I couldn't figure it out.
// For reference, here's an AS3 version (where BitmapData:draw takes a matrix argument):
// https://github.com/yonatan/volumetrics/blob/d3849027213e9499742cc4dfd2838c6032f4d9d3/src/org/zozuar/volumetrics/EffectContainer.as#L208-L209
grCtx.drawImage(
grCanvasEl,
(grW - grW * scaleFactor) / 2,
(grH - grH * scaleFactor) / 2 - sunY * scaleFactor + sunY,
grW * scaleFactor,
grH * scaleFactor
)
}
// Draw godrays to output canvas (whose globalCompositeOperation is already set to 'lighter').
ctx.drawImage(grCanvasEl, 0, 0, w, h);
}
}