I would like to "zoom" (mouse wheel) a grid I have, around the mouse position in canvas (like Desmos does).
By zooming, I mean redrawing the lines inside the canvas to look like a zoom effect, NOT performing an actual zoom.
And I would like to only use vanilla javascript and no libraries (so that I can learn).
At this point, I set up a very basic magnification effect that only multiplies the distance between the gridlines, based on the mouse wheel values.
////////////////////////////////////////////////////////////////////////////////
// User contants
const canvasWidth = 400;
const canvasHeight = 200;
const canvasBackground = '#282c34';
const gridCellColor = "#777";
const gridBlockColor = "#505050";
const axisColor = "white";
// Internal constants
const canvas = document.getElementById('canvas');
const context = canvas.getContext('2d', { alpha: false });
const bodyToCanvas = 8;
////////////////////////////////////////////////////////////////////////////////
// User variables
let cellSize = 10;
let cellBlock = 5;
let xSubdivs = 40
let ySubdivs = 20
// Internal variables
let grid = '';
let zoom = 0;
let xAxisOffset = xSubdivs/2;
let yAxisOffset = ySubdivs/2;
let mousePosX = 0;
let mousePosY = 0;
////////////////////////////////////////////////////////////////////////////////
// Classes
class Grid{
constructor() {
this.width = canvasWidth,
this.height = canvasHeight,
this.cellSize = cellSize,
this.cellBlock = cellBlock,
this.xSubdivs = xSubdivs,
this.ySubdivs = ySubdivs
}
draw(){
// Show canvas
context.fillStyle = canvasBackground;
context.fillRect(-this.width/2, -this.height/2, this.width, this.height);
// Horizontal lines
this.xSubdivs = Math.floor(this.height / this.cellSize);
for (let i = 0; i <= this.xSubdivs; i++) {this.setHorizontalLines(i);}
// Vertical lines
this.ySubdivs = Math.floor(this.width / this.cellSize);
for (let i = 0; i <= this.ySubdivs; i++) {this.setVerticalLines(i) ;}
// Axis
this.setAxis();
}
setHorizontalLines(i) {
// Style
context.lineWidth = 0.5;
if (i % this.cellBlock == 0) {
// light lines
context.strokeStyle = gridCellColor;
}
else{
// Dark lines
context.strokeStyle = gridBlockColor;
}
//Draw lines
context.beginPath();
context.moveTo(-this.width/2, (this.cellSize * i) - this.height/2);
context.lineTo( this.width/2, (this.cellSize * i) - this.height/2);
context.stroke();
context.closePath();
}
setVerticalLines(i) {
// Style
context.lineWidth = 0.5;
if (i % cellBlock == 0) {
// Light lines
context.strokeStyle = gridCellColor;
}
else {
// Dark lines
context.strokeStyle = gridBlockColor;
}
//Draw lines
context.beginPath();
context.moveTo((this.cellSize * i) - this.width/2, -this.height/2);
context.lineTo((this.cellSize * i) - this.width/2, this.height/2);
context.stroke();
context.closePath();
}
// Axis are separated from the line loops so that they remain on
// top of them (cosmetic measure)
setAxis(){
// Style x Axis
context.lineWidth = 1.5;
context.strokeStyle = axisColor;
// Draw x Axis
context.beginPath();
context.moveTo(-this.width/2, (this.cellSize * yAxisOffset) - this.height/2);
context.lineTo( this.width/2, (this.cellSize * yAxisOffset) - this.height/2);
context.stroke();
context.closePath();
// Style y axis
context.lineWidth = 1.5;
context.strokeStyle = axisColor;
// Draw y axis
context.beginPath();
context.moveTo((this.cellSize * xAxisOffset ) - this.width/2, -this.height/2);
context.lineTo((this.cellSize * xAxisOffset ) - this.width/2, this.height/2);
context.stroke();
context.closePath();
}
}
////////////////////////////////////////////////////////////////////////////////
// Functions
function init() {
// Set up canvas
if (window.devicePixelRatio > 1) {
canvas.width = canvasWidth * window.devicePixelRatio;
canvas.height = canvasHeight * window.devicePixelRatio;
context.scale(window.devicePixelRatio, window.devicePixelRatio);
}
else {
canvas.width = canvasWidth;
canvas.height = canvasHeight;
}
canvas.style.width = canvasWidth + "px";
canvas.style.height = canvasHeight + "px";
// Initialize coordinates in the middle of the canvas
context.translate(canvasWidth/2,canvasHeight/2)
// Setup the grid
grid = new Grid();
// Display the grid
grid.draw();
}
function setZoom(){
grid.cellSize = grid.cellSize + zoom;
grid.draw();
}
////////////////////////////////////////////////////////////////////////////////
//Launch the page
init();
////////////////////////////////////////////////////////////////////////////////
// Update the page on resize
window.addEventListener("resize", init);
// Zoom the canvas with mouse wheel
canvas.addEventListener('mousewheel', function (e) {
e.preventDefault();
e.stopPropagation();
zoom = e.wheelDelta/120;
requestAnimationFrame(setZoom);
})
// Get mouse coordinates on mouse move.
canvas.addEventListener('mousemove', function (e) {
e.preventDefault();
e.stopPropagation();
mousePosX = parseInt(e.clientX)-bodyToCanvas ;
mousePosY = parseInt(e.clientY)-bodyToCanvas ;
})
////////////////////////////////////////////////////////////////////////////////
html, body
{
background:#21252b;
width:100%;
height:100%;
margin:0px;
padding:0px;
overflow: hidden;
}
span{
color:white;
font-family: arial;
font-size: 12px;
margin:8px;
}
#canvas{
margin:8px;
border: 1px solid white;
}
<span>Use mouse wheel to zoom</span>
<canvas id="canvas"></canvas>
This works fine but, as expected, it magnifies the grid from the top left corner not from the mouse position.
So, I thought about detecting the mouse position and then modifying all the "moveTo" and "lineTo" parts. The goal would be to offset the magnified grid so that everything is displaced except the 2 lines intersecting the current mouse coordinates.
For instance, it feels to me that instead of this:
context.moveTo(
(this.cellSize * i) - this.width/2,
-this.height/2
);
I should have something like this:
context.moveTo(
(this.cellSize * i) - this.width/2 + OFFSET BASED ON MOUSE COORDS,
-this.height/2
);
But after hours of trials and errors, I'm stuck. So, any help would be appreciated.
FYI: I already coded a functional panning system that took me days to achieve (that I stripped from the code here for clarity), so I would like to keep most of the logic I have so far, if possible.
Unless my code is total nonsense or has performance issues, of course.
Thank you.
You're already tracking the mouse position in pixels. If you transform the mouse position to the coordinate system of your grid, you can define which grid cell it's hovering.
After zooming in, you can again calculate the mouse's coordinate. When zooming in around another center point, you'll see the coordinate shift.
You can undo that shift by translating the grid's center in the opposite direction.
Here's the main part of it:
function setZoom() {
// Calculate the mouse position before applying the zoom
// in the coordinate system of the grid
const x1 = (mousePosX - grid.centerX) / grid.cellSize;
const y1 = (mousePosY - grid.centerY) / grid.cellSize;
// Make the zoom happen: update the cellSize
grid.cellSize = grid.cellSize + zoom;
// After zoom, you'll see the coordinates changed
const x2 = (mousePosX - grid.centerX) / grid.cellSize;
const y2 = (mousePosY - grid.centerY) / grid.cellSize;
// Calculate the shift
const dx = x2 - x1;
const dy = y2 - y1;
// "Undo" the shift by shifting the coordinate system's center
grid.centerX += dx * grid.cellSize;
grid.centerY += dy * grid.cellSize;
grid.draw();
}
To make this easier to work with, I introduced a grid.centerX and .centerY. I updated your draw method to take the center in to account (and kind of butchered it along the way, but I think you'll manage to improve that a bit).
Here's the complete example:
////////////////////////////////////////////////////////////////////////////////
// User contants
const canvasWidth = 400;
const canvasHeight = 200;
const canvasBackground = '#282c34';
const gridCellColor = "#777";
const gridBlockColor = "#505050";
const axisColor = "white";
// Internal constants
const canvas = document.getElementById('canvas');
const context = canvas.getContext('2d', { alpha: false });
const bodyToCanvas = 8;
////////////////////////////////////////////////////////////////////////////////
// User variables
let cellSize = 10;
let cellBlock = 5;
let xSubdivs = 40
let ySubdivs = 20
// Internal variables
let grid = '';
let zoom = 0;
let xAxisOffset = xSubdivs/2;
let yAxisOffset = ySubdivs/2;
let mousePosX = 0;
let mousePosY = 0;
////////////////////////////////////////////////////////////////////////////////
// Classes
class Grid{
constructor() {
this.width = canvasWidth,
this.height = canvasHeight,
this.cellSize = cellSize,
this.cellBlock = cellBlock,
this.xSubdivs = xSubdivs,
this.ySubdivs = ySubdivs,
this.centerX = canvasWidth / 2,
this.centerY = canvasHeight / 2
}
draw(){
// Show canvas
context.fillStyle = canvasBackground;
context.fillRect(0, 0, this.width, this.height);
// Horizontal lines
const minIY = -Math.ceil(this.centerY / this.cellSize);
const maxIY = Math.ceil((this.height - this.centerY) / this.cellSize);
for (let i = minIY; i <= maxIY; i++) {this.setHorizontalLines(i);}
// Vertical lines
const minIX = -Math.ceil(this.centerX / this.cellSize);
const maxIX = Math.ceil((this.width - this.centerX) / this.cellSize);
for (let i = minIX; i <= maxIX; i++) {this.setVerticalLines(i) ;}
this.setVerticalLines(0);
this.setHorizontalLines(0);
}
setLineStyle(i) {
if (i === 0) {
context.lineWidth = 1.5;
context.strokeStyle = axisColor;
} else if (i % cellBlock == 0) {
// Light lines
context.lineWidth = 0.5;
context.strokeStyle = gridCellColor;
} else {
// Dark lines
context.lineWidth = 0.5;
context.strokeStyle = gridBlockColor;
}
}
setHorizontalLines(i) {
// Style
this.setLineStyle(i);
//Draw lines
const y = this.centerY + this.cellSize * i;
context.beginPath();
context.moveTo(0, y);
context.lineTo(this.width, y);
context.stroke();
context.closePath();
}
setVerticalLines(i) {
// Style
this.setLineStyle(i);
//Draw lines
const x = this.centerX + this.cellSize * i;
context.beginPath();
context.moveTo(x, 0);
context.lineTo(x, this.height);
context.stroke();
context.closePath();
}
}
////////////////////////////////////////////////////////////////////////////////
// Functions
function init() {
// Set up canvas
if (window.devicePixelRatio > 1) {
canvas.width = canvasWidth * window.devicePixelRatio;
canvas.height = canvasHeight * window.devicePixelRatio;
context.scale(window.devicePixelRatio, window.devicePixelRatio);
}
else {
canvas.width = canvasWidth;
canvas.height = canvasHeight;
}
canvas.style.width = canvasWidth + "px";
canvas.style.height = canvasHeight + "px";
// Setup the grid
grid = new Grid();
// Display the grid
grid.draw();
}
function setZoom() {
// Calculate the mouse position before applying the zoom
// in the coordinate system of the grid
const x1 = (mousePosX - grid.centerX) / grid.cellSize;
const y1 = (mousePosY - grid.centerY) / grid.cellSize;
grid.cellSize = grid.cellSize + zoom;
// After zoom, you'll see the coordinates changed
const x2 = (mousePosX - grid.centerX) / grid.cellSize;
const y2 = (mousePosY - grid.centerY) / grid.cellSize;
// Calculate the shift
const dx = x2 - x1;
const dy = y2 - y1;
// "Undo" the shift by shifting the coordinate system's center
grid.centerX += dx * grid.cellSize;
grid.centerY += dy * grid.cellSize;
grid.draw();
}
////////////////////////////////////////////////////////////////////////////////
//Launch the page
init();
////////////////////////////////////////////////////////////////////////////////
// Update the page on resize
window.addEventListener("resize", init);
// Zoom the canvas with mouse wheel
canvas.addEventListener('mousewheel', function (e) {
e.preventDefault();
e.stopPropagation();
zoom = e.wheelDelta/120;
requestAnimationFrame(setZoom);
})
// Get mouse coordinates on mouse move.
canvas.addEventListener('mousemove', function (e) {
e.preventDefault();
e.stopPropagation();
mousePosX = parseInt(e.clientX)-bodyToCanvas ;
mousePosY = parseInt(e.clientY)-bodyToCanvas ;
})
////////////////////////////////////////////////////////////////////////////////
html, body
{
background:#21252b;
width:100%;
height:100%;
margin:0px;
padding:0px;
overflow: hidden;
}
span{
color:white;
font-family: arial;
font-size: 12px;
margin:8px;
}
#canvas{
margin:8px;
border: 1px solid white;
}
<canvas id="canvas"></canvas>
So I have this project I have been working on and the goal of it is to randomly generate terrain on a 2D plane, and put rain in the background, and I chose to use the html5 canvas element to accomplish this goal. After creating it I am happy with the result but I am having performance issues and could use some advice on how to fix it. So far I have tried to only clear the bit of the canvas that is needed, which is above the rectangles I drew under the terrain to fill it in, but because of this I have to redraw the circles. The rn(rain number) has already been lowered by about 2 times and it still lags, any suggestions?
Note - The code in the snippet does not lag due to it's small size, but if I was to run it in full screen with the actual rain number(800), it would lag. I have shrunk the values to fit the snippet.
var canvas = document.getElementById('gamecanvas');
var c = canvas.getContext('2d');
var ma = Math.random;
var mo = Math.round;
var wind = 5;
var rn = 100;
var rp = [];
var tp = [];
var tn;
function setup() {
//fillstyle
c.fillStyle = 'black';
//canvas size
canvas.height = window.innerHeight;
canvas.width = window.innerWidth;
//rain setup
for (i = 0; i < rn; i++) {
let x = mo(ma() * canvas.width);
let y = mo(ma() * canvas.width);
let w = mo(ma() * 1) + 1;
let s = mo(ma() * 5) + 10;
rp[i] = { x, y, w, s };
}
//terrain setup
tn = (canvas.width) + 20;
tp[0] = { x: -2, y: canvas.height - 50 };
for (i = 1; i <= tn; i++) {
let x = tp[i - 1].x + 2;
let y = tp[i - 1].y + (ma() * 20) - 10;
if (y > canvas.height - 50) {
y = tp[i - 1].y -= 1;
}
if (y < canvas.height - 100) {
y = tp[i - 1].y += 1;
}
tp[i] = { x, y };
c.fillRect(x, y, 4, canvas.height - y);
}
}
function gameloop() {
//clearing canvas
for (i = 0; i < tn; i++) {
c.clearRect(tp[i].x - 2, 0, 2, tp[i].y);
}
for (i = 0; i < rn; i++) {
//rain looping
if (rp[i].y > canvas.height + 5) {
rp[i].y = -5;
}
if (rp[i].x > canvas.width + 5) {
rp[i].x = -5;
}
//rain movement
rp[i].y += rp[i].s;
rp[i].x += wind;
//rain drawing
c.fillRect(rp[i].x, rp[i].y, rp[i].w, 6);
}
for (i = 0; i < tn; i++) {
//terrain drawing
c.beginPath();
c.arc(tp[i].x, tp[i].y, 6, 0, 7);
c.fill();
}
}
setup();
setInterval(gameloop, 1000 / 60);
body {
background-color: white;
overflow: hidden;
margin: 0;
}
canvas {
background-color: white;
}
<html>
<head>
<link rel="stylesheet" href="index.css">
<title>A Snowy Night</title>
</head>
<body id="body"> <canvas id="gamecanvas"></canvas>
<script src="index.js"></script>
</body>
</html>
Superimposing canvas
Like I suggested in my comment, the use of a second canvas point is to only have to draw the terrain once, and hence it could enhance the performance of your animation by saving a redraw on each new frame. This can be done with CSS by positioning one on the other (like layers).
#canvasBase {
position: relative;
}
#canvasLayer1 {
position: absolute;
top: 0;
left: 0;
}
#canvasLayer2 {
position: absolute;
top: 0;
left: 0;
}
// etc...
Also I advise you to use requestAnimationFrame over setinterval (see why).
requestAnimationFrame
However, by using requestAnimationFrame, we don't control the refresh rate, it's tied to the client hardware. So we need to handle it and for that, we will use the DOMHighResTimeStamp which is passed as an argument to our callback method.
The idea is to let it run at native speed and manage the fps by updating the logic (our calculs) only at desired time. For exemple, if we need a fps = 60; that means we need to update our logic every 1000 / 60 = ~16,67 ms. So we check if the deltaTime with the time of the last frame is equal or superior than ~16,67ms. If not enough time elapsed, we call a new frame & we return (important, otherwise the control we just did is useless as the code keeps going whatever the outcome of it).
let fps = 60;
/* Check if we need to update the logic */
/* if not request a new frame & return */
if(deltaLastUpdate <= 1000 / fps){ // 1000 / 60 = ~16,67ms
requestAnimationFrame(animate);
return;
}
Clearing canvas
As you need to erase all the past rain drops, the simplest & cheapest in ressources in to clear the whole context in one swoop.
ctxRain.clearRect(0, 0, rainCanvas.width, rainCanvas.height);
Path2D
As your drawing use the same color for the rain drops, you can as well group all these in one path:
rainPath = new Path2D();
...
So you will need only one instruction to draw them (same ressources saving type as the clearRect):
ctxRain.fill(rainPath);
Result
/* CANVAS "Terrain" */
const terrainCanvas = document.getElementById('gameTerrain');
const ctxTerrain = terrainCanvas.getContext('2d');
terrainCanvas.height = window.innerHeight;
terrainCanvas.width = window.innerWidth;
/* CANVAS "Rain" */
const rainCanvas = document.getElementById('gameRain');
const ctxRain = rainCanvas.getContext('2d');
rainCanvas.height = window.innerHeight;
rainCanvas.width = window.innerWidth;
/* Game Constants */
const wind = 5;
const rainMaxParticules = 100;
const rain = [];
let rainPath;
const terrainMaxParticules = terrainCanvas.width + 20;
const terrain = [];
let terrainPath;
/* Maths help */
const ma = Math.random;
const mo = Math.round;
/* Clear */
function clearTerrain(){
ctxTerrain.clearRect(0, 0, terrainCanvas.width, terrainCanvas.height);
}
function clearRain(){
ctxRain.clearRect(0, 0, rainCanvas.width, rainCanvas.height);
}
/* Logic */
function initTerrain(){
terrain[0] = { x: -2, y: terrainCanvas.height - 50 };
for (let i = 1; i <= terrainMaxParticules; i++) {
let x = terrain[i - 1].x + 2;
let y = terrain[i - 1].y + (ma() * 20) - 10;
if (y > terrainCanvas.height - 50) {
y = terrain[i - 1].y -= 1;
}
if (y < terrainCanvas.height - 100) {
y = terrain[i - 1].y += 1;
}
terrain[i] = { x, y };
}
}
function initRain(){
for (let i = 0; i < rainMaxParticules; i++) {
let x = mo(ma() * rainCanvas.width);
let y = mo(ma() * rainCanvas.width);
let w = mo(ma() * 1) + 1;
let s = mo(ma() * 5) + 10;
rain[i] = { x, y, w, s };
}
}
function init(){
initTerrain();
initRain();
}
function updateTerrain(){
terrainPath = new Path2D();
for(let i = 0; i < terrain.length; i++){
terrainPath.arc(terrain[i].x, terrain[i].y, 6, Math.PI/2, 5*Math.PI/2);
}
terrainPath.lineTo(terrainCanvas.width, terrainCanvas.height);
terrainPath.lineTo(0, terrainCanvas.height);
}
function updateRain(){
rainPath = new Path2D();
for (let i = 0; i < rain.length; i++) {
// Rain looping
if (rain[i].y > rainCanvas.height + 5) {
rain[i].y = -5;
}
if (rain[i].x > rainCanvas.width + 5) {
rain[i].x = -5;
}
// Rain movement
rain[i].y += rain[i].s;
rain[i].x += wind;
// Path containing all the drops
rainPath.rect(rain[i].x, rain[i].y, rain[i].w, 6);
}
}
/* Drawing */
function drawTerrain(){
ctxTerrain.fillStyle = 'black';
ctxTerrain.fill(terrainPath);
}
function drawRain(){
ctxRain.fillStyle = 'black';
ctxRain.fill(rainPath);
}
/* Animation Constant */
const fps = 60;
let lastTimestampUpdate;
let terrainDrawn = false;
/* Game loop */
function animate(timestamp){
/* Initialize rain & terrain particules */
if(rain.length === 0 || terrain.length === 0){
init();
}
/* Define "lastTimestampUpdate" from the first call */
if (lastTimestampUpdate === undefined){
lastTimestampUpdate = timestamp;
}
/* Check if we need to update the logic & the drawing, if not, request a new frame & return */
if(timestamp - lastTimestampUpdate <= 1000 / fps){
requestAnimationFrame(animate);
return;
}
if(!terrainDrawn){
/* Terrain --------------------- */
/* Clear */
clearTerrain();
/* Logic */
updateTerrain();
/* Draw */
drawTerrain();
/* ----------------------------- */
terrainDrawn = true;
}
/* --- Rain -------------------- */
/* Clear */
clearRain();
/* Logic */
updateRain();
/* Draw */
drawRain();
/* ----------------------------- */
/* Request another frame */
lastTimestampUpdate = timestamp;
requestAnimationFrame(animate);
}
/* Start the animation */
requestAnimationFrame(animate);
body {
background-color: white;
overflow: hidden;
margin: 0;
}
#gameTerrain {
position: relative;
}
#gameRain {
position: absolute;
top: 0;
left: 0;
}
<body>
<canvas id="gameTerrain"></canvas>
<canvas id="gameRain"></canvas>
</body>
Aside
This won't affect performance, however I encourage you to use const & let over var (What's the difference between using “let” and “var”?).
Generally, having more paint instructions will be what costs the most, the complexity of these paint instructions only comes to play when it's really complex.
Here you are spamming the GPU with paint instructions:
(canvas.width) + 20 calls to clearRect(). clearRect() is a paint instruction, and not a cheap one. Use it sporadically, but actually, you should use it only to clear the whole context.
One fillRect() per rain drop.. They're all the same color, they can be merged in a single sub-path and drawn in a single draw call.
One fill per circle composing the terrain.
So instead of this huge number of draw calls, we could make it in only two draw calls:
One clearRect, one fill() of one big subpath containing both the drops and
the terrain.
However it's certainly more practical to keep the terrain and the rain separated, so let's make it three draw calls, by keeping the terrain in its own Path2D object, which is more friendly for the CPU:
var canvas = document.getElementById('gamecanvas');
var c = canvas.getContext('2d');
var ma = Math.random;
var mo = Math.round;
var wind = 5;
var rn = 100;
var rp = [];
// this will hold our Path2D object
// which will hold the full terrain drawing
// set a 'let' because we will set it again on resize
let terrain;
var tp = [];
var tn;
function setup() {
//fillstyle
c.fillStyle = 'black';
//canvas size
canvas.height = window.innerHeight;
canvas.width = window.innerWidth;
//rain setup
for (let i = 0; i < rn; i++) {
let x = mo(ma() * canvas.width);
let y = mo(ma() * canvas.width);
let w = mo(ma() * 1) + 1;
let s = mo(ma() * 5) + 10;
rp[i] = { x, y, w, s };
}
//terrain setup
tn = (canvas.width) + 20;
tp[0] = { x: -2, y: canvas.height - 50 };
terrain = new Path2D();
for (let i = 1; i <= tn; i++) {
let x = tp[i - 1].x + 2;
let y = tp[i - 1].y + (ma() * 20) - 10;
if (y > canvas.height - 50) {
y = tp[i - 1].y -= 1;
}
if (y < canvas.height - 100) {
y = tp[i - 1].y += 1;
}
tp[i] = { x, y };
terrain.rect(x, y, 4, canvas.height - y);
terrain.arc(x, y, 6, 0, Math.PI*2);
}
}
function gameloop() {
// clear the whole canvas
c.clearRect(0, 0, canvas.width, canvas.height);
// start a new sub-path for the rain
c.beginPath();
for (let i = 0; i < rn; i++) {
//rain looping
if (rp[i].y > canvas.height + 5) {
rp[i].y = -5;
}
if (rp[i].x > canvas.width + 5) {
rp[i].x = -5;
}
//rain movement
rp[i].y += rp[i].s;
rp[i].x += wind;
//rain tracing
c.rect(rp[i].x, rp[i].y, rp[i].w, 6);
}
// paint all the drops in a single op
c.fill();
// paint the whole terrain in a single op
c.fill(terrain);
// loop at screen refresh frequency
requestAnimationFrame(gameloop);
}
setup();
requestAnimationFrame(gameloop);
onresize = () => setup();
body {
background-color: white;
overflow: hidden;
margin: 0;
}
canvas {
background-color: white;
}
<canvas id="gamecanvas"></canvas>
Further possible improvements:
Instead of making our terrain path a set of rectangles, using only lineTo to trace the actual outline would probably help a bit, some more calculations at init, but it's done only once in a while.
If the terrain becomes more complex, with more details, or with various colors and shadows etc. then consider painting it only once, and then produce an ImageBitmap from the canvas. Then in gameLoop you'll just have to drawImage that ImageBitmap (drawing bitmaps is super fast, but storing it consumes memory, so remember to .close() the ImageBitmap when you don't need it anymore).
Can anyone give me a hint how I can create something like that with javascript:
The requirement is that I can set the density of the flakes. and add up to 5 different colors.
I do know how to create a canvas and put pixels in there, but I don't know how to create the "flakes".
Is there a way to create random shapes like this?
You can tessellate a simple shape and draw it at some random point.
The example below will create a 3 sided point, testate it randomly to a detail level of about 2 pixels and then add it to a path.
Then the path is filled with a color and another set of shapes are added.
function testate(amp, points) {
const p = [];
var i = points.length - 2, x1, y1, x2, y2;
p.push(x1 = points[i++]);
p.push(y1 = points[i]);
i = 0;
while (i < points.length) {
x2 = points[i++];
y2 = points[i++];
const dx = x2 - x1;
const dy = y2 - y1;
const r = (Math.random() - 0.5) * 2 * amp;
p.push(x1 + dx / 2 - dy * r);
p.push(y1 + dy / 2 + dx * r);
p.push(x1 = x2);
p.push(y1 = y2);
}
return p;
}
function drawFlake(ctx, size, x, y, noise) {
const a = Math.random() * Math.PI;
var points = [];
const step = Math.PI * (2/3);
var i = 0;
while (i < 3) {
const r = (Math.random() * size + size) / 2;
points.push(Math.cos(a + i * step) * r);
points.push(Math.sin(a + i * step) * r);
i++;
}
while (size > 2) {
points = testate(noise, points);
size >>= 1;
}
i = 0;
ctx.setTransform(1,0,0,1,x,y);
ctx.moveTo(points[i++], points[i++]);
while (i < points.length) {
ctx.lineTo(points[i++], points[i++]);
}
}
function drawRandomFlakes(ctx, count, col, min, max, noise) {
ctx.fillStyle = col;
ctx.beginPath();
while (count-- > 0) {
const x = Math.random() * ctx.canvas.width;
const y = Math.random() * ctx.canvas.height;
const size = min + Math.random() * (max- min);
drawFlake(ctx, size, x, y, noise);
}
ctx.fill();
}
const ctx = canvas.getContext("2d");
canvas.addEventListener("click",drawFlakes);
drawFlakes();
function drawFlakes(){
ctx.setTransform(1,0,0,1,0,0);
ctx.fillStyle = "#341";
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height)
const noise = Math.random() * 0.3 + 0.3;
drawRandomFlakes(ctx, 500, "#572", 5, 10, noise)
drawRandomFlakes(ctx, 200, "#421", 10, 15, noise)
drawRandomFlakes(ctx, 25, "#257", 15, 30, noise)
}
body { background: #341 }
div {
position: absolute;
top: 20px;
left: 20px;
color: white;
}
<canvas id="canvas" width = "600" height = "512"></canvas>
<div>Click to redraw</div>
You'll need a certain noise algorithm.
In this example I used Perlin noise, but you can use any noise algorithm that fits your needs. By using Perlin noise we can define a blob as an area where the noise value is above a certain threshold.
I used a library that I found here and based my code on the sample code. The minified code is just a small portion of it (I cut out simplex and perlin 3D).
LICENSE
You can tweek it by changing the following parameters
Math.abs(noise.perlin2(x / 25, y / 25))
Changing the 25 to a higher value will zoom in, lower will zoom out
if (value > 0.4){
Changing the 0.4 to a lower value will increase blob size, higher will decrease blob size.
!function(n){var t=n.noise={};function e(n,t,e){this.x=n,this.y=t,this.z=e}e.prototype.dot2=function(n,t){return this.x*n+this.y*t},e.prototype.dot3=function(n,t,e){return this.x*n+this.y*t+this.z*e};var r=[new e(1,1,0),new e(-1,1,0),new e(1,-1,0),new e(-1,-1,0),new e(1,0,1),new e(-1,0,1),new e(1,0,-1),new e(-1,0,-1),new e(0,1,1),new e(0,-1,1),new e(0,1,-1),new e(0,-1,-1)],o=[151,160,137,91,90,15,131,13,201,95,96,53,194,233,7,225,140,36,103,30,69,142,8,99,37,240,21,10,23,190,6,148,247,120,234,75,0,26,197,62,94,252,219,203,117,35,11,32,57,177,33,88,237,149,56,87,174,20,125,136,171,168,68,175,74,165,71,134,139,48,27,166,77,146,158,231,83,111,229,122,60,211,133,230,220,105,92,41,55,46,245,40,244,102,143,54,65,25,63,161,1,216,80,73,209,76,132,187,208,89,18,169,200,196,135,130,116,188,159,86,164,100,109,198,173,186,3,64,52,217,226,250,124,123,5,202,38,147,118,126,255,82,85,212,207,206,59,227,47,16,58,17,182,189,28,42,223,183,170,213,119,248,152,2,44,154,163,70,221,153,101,155,167,43,172,9,129,22,39,253,19,98,108,110,79,113,224,232,178,185,112,104,218,246,97,228,251,34,242,193,238,210,144,12,191,179,162,241,81,51,145,235,249,14,239,107,49,192,214,31,181,199,106,157,184,84,204,176,115,121,50,45,127,4,150,254,138,236,205,93,222,114,67,29,24,72,243,141,128,195,78,66,215,61,156,180],i=new Array(512),w=new Array(512);function u(n){return n*n*n*(n*(6*n-15)+10)}function f(n,t,e){return(1-e)*n+e*t}t.seed=function(n){n>0&&n<1&&(n*=65536),(n=Math.floor(n))<256&&(n|=n<<8);for(var t=0;t<256;t++){var e;e=1&t?o[t]^255&n:o[t]^n>>8&255,i[t]=i[t+256]=e,w[t]=w[t+256]=r[e%12]}},t.seed(0),t.perlin2=function(n,t){var e=Math.floor(n),r=Math.floor(t);n-=e,t-=r;var o=w[(e&=255)+i[r&=255]].dot2(n,t),h=w[e+i[r+1]].dot2(n,t-1),s=w[e+1+i[r]].dot2(n-1,t),a=w[e+1+i[r+1]].dot2(n-1,t-1),c=u(n);return f(f(o,s,c),f(h,a,c),u(t))}}(this);
const c = document.getElementById("canvas");
const cc = c.getContext("2d");
noise.seed(Math.random());
let image = cc.createImageData(canvas.width, canvas.height);
let data = image.data;
for (let x = 0; x < c.width; x++){
for (let y = 0; y < c.height; y++){
const value = Math.abs(noise.perlin2(x / 25, y / 25));
const cell = (x + y * c.width) * 4;
if (value > 0.4){
data[cell] = 256;
data[cell + 1] = 0;
data[cell + 2] = 0;
data[cell + 3] = 256;
}
else {
data[cell] = 0;
data[cell + 1] = 0;
data[cell + 2] = 0;
data[cell + 3] = 0;
}
}
}
cc.putImageData(image, 0, 0);
<canvas id="canvas" width=500 height=500></canvas>
I am trying to create animated arc that doesn't look blurry on HiDPI devices.
This is how my arc looks on iPhone 5s:
you can see that near 0, 90, 180 deg arc becomes too sharp. How can I prevent this?
This is my code:
// Canvas arc progress
const can = document.getElementById('canvas');
const ctx = can.getContext('2d');
const circ = Math.PI * 2;
const quart = Math.PI / 2;
const canvasSize = can.offsetWidth;
const halfCanvasSize = canvasSize / 2;
let start = 0,
finish = 70,
animRequestId = null;
// Get pixel ratio
const ratio = (function() {
const dpr = window.devicePixelRatio || 1,
bsr = ctx.webkitBackingStorePixelRatio ||
ctx.mozBackingStorePixelRatio ||
ctx.msBackingStorePixelRatio ||
ctx.oBackingStorePixelRatio ||
ctx.backingStorePixelRatio || 1;
return dpr / bsr;
})();
// Set canvas h & w
can.width = can.height = canvasSize * ratio;
can.style.width = can.style.height = canvasSize + 'px';
ctx.scale(ratio, ratio)
ctx.beginPath();
ctx.strokeStyle = 'rgb(120,159,194)';
ctx.lineCap = 'square';
ctx.lineWidth = 8.0;
ctx.arc(halfCanvasSize, halfCanvasSize, halfCanvasSize - 4, 0, circ, false);
ctx.stroke();
ctx.closePath();
ctx.beginPath();
ctx.strokeStyle = 'rgb(244,247,255)';
ctx.lineCap = 'round';
ctx.lineWidth = 8.0;
ctx.closePath();
let imd = ctx.getImageData(0, 0, canvasSize, canvasSize);
const draw = (current) => {
ctx.putImageData(imd, 0, 0);
ctx.beginPath();
ctx.arc(halfCanvasSize, halfCanvasSize, halfCanvasSize - 4, -(quart), ((circ) * current) - quart, false);
ctx.stroke();
};
(function animateArcProgress() {
animRequestId = requestAnimationFrame(animateArcProgress);
if (start <= finish) {
draw(start / 100);
start += 2;
} else {
cancelAnimationFrame(animRequestId);
}
})();
body {
margin: 0;
}
div {
display: flex;
align-items: center;
justify-content: center;
height: 300px;
widht: 300px;
background: #85b1d7;
}
canvas {
height: 250px;
width: 250px;
}
<div>
<canvas id='canvas'></canvas>
</div>
You can soften the edge by drawing 1/2 pixel inside as done in your code below.
I rendered the arc 3 times at 8, 7.5 and 7 pixel width width alpha colour values 0.25, 0.5 and 1 respectively.
You can make it as soft as you want.
BTW using putImageData is very slow, why not just render the background to another canvas and draw that via ctx.drawImage(offScreencanvas,0,0) that way you will use the GPU to render the background rather than the CPU via the graphics port bus.
I added a bit more code to show the different softening FX you can get and added a mouse zoom so you can see the pixels a little better.
const can = document.getElementById('canvas');
const can2 = document.createElement("canvas"); // off screen canvas
const can3 = document.createElement("canvas"); // off screen canvas
const ctx = can.getContext('2d');
const ctx2 = can2.getContext('2d');
const ctx3 = can3.getContext('2d');
const circ = Math.PI * 2;
const quart = Math.PI / 2;
const canvasSize = can.offsetWidth;
const halfCanvasSize = canvasSize / 2;
const mouse = {x : null, y : null};
can.addEventListener("mousemove",function(e){
var bounds = can.getBoundingClientRect();
mouse.x = e.clientX - bounds.left;
mouse.y = e.clientY - bounds.top;
});
let start = 0,
finish = 70,
animRequestId = null;
// Get pixel ratio
const ratio = (function() {
const dpr = window.devicePixelRatio || 1,
bsr = ctx.webkitBackingStorePixelRatio ||
ctx.mozBackingStorePixelRatio ||
ctx.msBackingStorePixelRatio ||
ctx.oBackingStorePixelRatio ||
ctx.backingStorePixelRatio || 1;
return dpr / bsr;
})();
// Set canvas h & w
can2.height = can3.height = can2.width = can3.width = can.width = can.height = canvasSize * ratio;
can.style.width = can.style.height = canvasSize + 'px';
ctx.scale(ratio, ratio)
ctx2.scale(ratio, ratio)
ctx3.scale(ratio, ratio)
ctx2.beginPath();
ctx2.strokeStyle = 'rgb(120,159,194)';
ctx2.lineCap = 'square';
ctx2.lineWidth = 8.0;
ctx2.arc(halfCanvasSize, halfCanvasSize, halfCanvasSize - 4, 0, circ, false);
ctx2.stroke();
ctx2.closePath();
ctx2.beginPath();
ctx2.strokeStyle = 'rgb(244,247,255)';
ctx2.lineCap = 'round';
ctx2.lineWidth = 8.0;
ctx2.closePath();
const draw = (current) => {
ctx3.clearRect(0,0,canvas.width,canvas.height);
ctx3.drawImage(can2,0,0);
var rad = halfCanvasSize - 4;
const drawArc = () => {
ctx3.beginPath();
ctx3.arc(halfCanvasSize, halfCanvasSize, rad, -(quart), ((circ) * current) - quart, false);
ctx3.stroke();
}
// draw soft
ctx3.strokeStyle = 'rgb(244,247,255)';
ctx3.lineWidth = 8.5;
ctx3.globalAlpha = 0.25;
drawArc();;
ctx3.lineWidth = 7.0;
ctx3.globalAlpha = 0.5;
drawArc();;
ctx3.lineWidth = 6.5;
ctx3.globalAlpha = 1;
drawArc();
// draw normal
rad -= 12;
ctx3.lineWidth = 8.0;
ctx3.globalAlpha = 1;
drawArc();;
// draw ultra soft
rad -= 12;
ctx3.strokeStyle = 'rgb(244,247,255)';
ctx3.lineWidth = 9.0;
ctx3.globalAlpha = 0.1;
drawArc();
ctx3.lineWidth = 8.0;
ctx3.globalAlpha = 0.2;
drawArc();;
ctx3.lineWidth = 7.5;
ctx3.globalAlpha = 0.5;
drawArc();
ctx3.lineWidth = 6;
ctx3.globalAlpha = 1;
drawArc();
};
const zoomW = 30;
const zoomAmount = 5;
const drawZoom = () => {
ctx.drawImage(can3,0,0);
var width = zoomW * zoomAmount;
var cx = mouse.x - width / 2;
var cy = mouse.y - width / 2;
var c1x = mouse.x - zoomW / 2;
var c1y = mouse.y - zoomW / 2;
ctx.strokeStyle = 'rgb(244,247,255)';
ctx.lineWidth = 4;
ctx.strokeRect(cx,cy,width,width);
ctx.clearRect(cx,cy,width,width);
ctx.imageSmoothingEnabled = false;
ctx.mozImageSmoothingEnabled = false;
ctx.drawImage(can3,c1x,c1y,zoomW,zoomW,cx,cy,width,width);
ctx.imageSmoothingEnabled = true;
ctx.mozImageSmoothingEnabled = true;
}
function keepUpdating(){
ctx.clearRect(0,0,can.width,can.height);
drawZoom();
requestAnimationFrame(keepUpdating);
}
(function animateArcProgress() {
ctx.clearRect(0,0,can.width,can.height);
draw(start / 100);
drawZoom();
if (start <= finish) {
start += 0.5;
requestAnimationFrame(animateArcProgress);
} else {
requestAnimationFrame(keepUpdating);
}
})();
body {
margin: 0;
}
div {
display: flex;
align-items: center;
justify-content: center;
height: 300px;
widht: 300px;
background: #85b1d7;
}
canvas {
height: 250px;
width: 250px;
}
<div>
<canvas id='canvas'></canvas>
</div>