I have this on-going project called map editor where i can create map and customize map for future project, that involved map. From here i learn how to use canvas, to use vanilla JavaScript and html data-*. I manage to build my procedural generated map where i click button it will create a map, and of course i able to customize the variable of the input to generate new map.
When i manage to create the procedural generated map i got hit by bottleneck where generating map is too slow. So i debug the program and there are two culprits. The canvas render and the map generator, i have 1200px x 700px canvas size and i want to fill this canvas with generated map.
The Slow Canvas
This is my second time play with canvas, and as a new player to canvas. And most tutorial in canvas they always suggest you to
ctx.fillStyle = "#ff0000"
ctx.fillRect(0,0,1,1)
this is good for first time learner. But once you step into something complicated, like render 1000px x 700px at once that will be a bottleneck. Why? it simple let's create a code
const pixelSize = 1
const mapSize = [][]
for (let y = 0; y < mapSize.length; y++) {
for (let x = 0; x < mapSize[y].width; x++) {
const color = mapSize[y][x].color
ctx.fillStyle = `rgba(${color.r},${color.g},${color.b},1)`;
ctx.fillRect(x*pixelSize,y*pixelSize,pixelSize,pixelSize)
}
}
This seems innocent but if you have canvas size of 1200px x 768px, it slow.
A 1200 x 768 = 921,600 pixel, using two nested loops, that translates to nearly a million calls to ctx.fillRect. Each call crosses the JavaScript → browser rendering boundary and updates canvas state, the browser can’t efficiently batch these operations, so the CPU becomes the bottleneck long before the GPU is fully utilized.
So what we need is a batch update since ctx.fillRect can't do batch. We have to find something.
The alternative is ctx.putImageData which allows direct, bulk access to the canvas pixel buffer. example code below:
const mapSize = [][]
// create buffer of pixel image data with a size of n x m
let imageData = ctx.createImageData(mapSize[0].length, mapSize.length)
let index = 0
for (let y = 0; y < mapSize.length; y++) {
for (let x = 0; x < mapSize[y].length; x++) {
const color = mapSize[y][x].color
imageData.data[index++] = color.r
imageData.data[index++] = color.g
imageData.data[index++] = color.b
imageData.data[index++] = 255
}
}
ctx.putImageData(imageData, 0, 0)
The imageData is a flat array following [r,g,b,a,....] layout. If not follow the layout, it will draw but it will produce unexpected result.
You can see that i didn't called ctx.putImageData inside the loop and i only called once. And i put all information in memory and draw it after loop complete. The amount loop is still the same but the content inside loop is just store information.
if you put
ctx.putImageDatainside a same loop as above example it will not improve because you will cause CPU overhead again.
This approach significantly improves performance for full-canvas, per-pixel rendering. It’s not a universal replacement for ctx.fillRect, but it’s the right tool when you need to update many pixels at once.
Slow Map Generator
In Map Generator i use Fractal Brownian Noise And Perlin Noise for detail explanation what are those two, i recommend you to watch and read the detail at Perlin Noise and Perlin Noise.
I'm not gonna go into detail about those algo but a simple explanation is that natural terrain changes smoothly over space, and Perlin-based noise models this behavior by producing spatially correlated values.
And Fractal Brownian Noise builds on this idea by layering multiple Perlin Noise functions at different scales, adding both large shapes and small details.
function generator(options) {
const { permutationTable, width, height } = editorState
let noises = []
let max = -Infinity
let min = Infinity
for (let y = 0; y < height; y++) {
noises[y] = []
for (let x = 0; x < width; x++) {
const noise = FractalBrownianNoise(x, y, permutationTable, options)
if (noise > max) {
max = noise
}
if (noise < min) {
min = noise
}
noises[y][x] = noise
}
}
}
Generating noise at full resolution means evaluating FractalBrownianNoise hundreds of thousands of times per update. While this works for small canvases, it becomes expensive at larger sizes and causes visible lag in the browser.
function generator(options) {
const { permutationTable, width, height } = editorState
let noises = []
let max = -Infinity
let min = Infinity
const sampleHeight = Math.floor(height / 3)
const sampleWidth = Math.floor(width / 3)
for (let y = 0; y < sampleHeight; y++) {
noises[y] = []
for (let x = 0; x < sampleWidth; x++) {
const noise = FractalBrownianNoise(x, y, permutationTable, options)
noises[y][x] = noise
}
}
}
Instead of generating noise at full resolution, I switched to a sampling approach. I evaluate the noise function on a lower-resolution grid and treat it as a sampled version of the final map. In this case, the original 1200 × 768 map is sampled down to 400 × 256 by dividing both dimensions by 3. This reduces the number of noise evaluations by almost 9×, significantly speeding up generation. The sampling rate can be adjusted depending on what the device can handle.
Once the noise is sampled at a lower resolution, the next step is reconstruction. To restore the map to its original size, I use bilinear interpolation to estimate the values between samples.
function generator(options) {
const { permutationTable, width, height } = editorState
let noises = []
let max = -Infinity
let min = Infinity
const sampleHeight = Math.floor(height / 3)
const sampleWidth = Math.floor(width / 3)
for (let y = 0; y < sampleHeight; y++) {
noises[y] = []
for (let x = 0; x < sampleWidth; x++) {
const noise = FractalBrownianNoise(x, y, permutationTable, options)
noises[y][x] = noise
}
}
// back to original
const scaledMap = bilinearInterpolation(noises, width, height)
}
I use bilinearInterpolation because the nature of blending value to create smooth upscale image and since FractalBrownianNoise is smooth by nature, it make a perfect candidate to combine.
End
Well that's two of it, i can go even further like use OffscreenCanvas to render but that not necessary for now since i already reach target performance i want. Here are a sample code of mine
Gist Code
Top comments (0)