Renderização de pixel art em jogos web
Pixel art fica incrível quando renderizada corretamente — e péssima quando está borrada. Este tutorial aborda as técnicas para criar gráficos em pixels nítidos e autênticos.
1) O problema do desfoque
Por padrão, os navegadores suavizam as imagens ao redimensioná-las. Para pixel art, isso é inadequado:
css
/* CSS solution */
canvas {
image-rendering: pixelated;
image-rendering: crisp-edges; /* Firefox fallback */
}2) Configurações do contexto do Canvas
js
const canvas = document.getElementById('game')
const ctx = canvas.getContext('2d')
// Disable smoothing for crisp pixels
ctx.imageSmoothingEnabled = falseEssa configuração deve ser aplicada novamente após cada redimensionamento do Canvas ou alteração de contexto.
3) Escalonamento por números inteiros
Pixel art fica melhor em escalas inteiras (1x, 2x, 3x, 4x):
js
const GAME_WIDTH = 160
const GAME_HEIGHT = 144 // Game Boy resolution
function resize() {
const maxScale = Math.floor(Math.min(
window.innerWidth / GAME_WIDTH,
window.innerHeight / GAME_HEIGHT
))
const scale = Math.max(1, maxScale)
canvas.width = GAME_WIDTH
canvas.height = GAME_HEIGHT
canvas.style.width = GAME_WIDTH * scale + 'px'
canvas.style.height = GAME_HEIGHT * scale + 'px'
ctx.imageSmoothingEnabled = false
}4) Desenhando sprites em posições inteiras
Posições de subpixel causam desfoque:
js
// Bad: causes blur
ctx.drawImage(sprite, 10.5, 20.3)
// Good: crisp pixels
ctx.drawImage(sprite, Math.floor(10.5), Math.floor(20.3))
// Or round to nearest
ctx.drawImage(sprite, Math.round(player.x), Math.round(player.y))5) Animação com sprite sheet
js
class PixelSprite {
constructor(image, frameWidth, frameHeight) {
this.image = image
this.frameWidth = frameWidth
this.frameHeight = frameHeight
this.animations = {}
this.currentAnim = null
this.frame = 0
this.timer = 0
}
addAnimation(name, frames, fps = 8) {
this.animations[name] = { frames, duration: 1 / fps }
}
play(name) {
if (this.currentAnim !== name) {
this.currentAnim = name
this.frame = 0
this.timer = 0
}
}
update(dt) {
if (!this.currentAnim) return
const anim = this.animations[this.currentAnim]
this.timer += dt
if (this.timer >= anim.duration) {
this.timer = 0
this.frame = (this.frame + 1) % anim.frames.length
}
}
draw(ctx, x, y, flipX = false) {
if (!this.currentAnim) return
const anim = this.animations[this.currentAnim]
const frameIndex = anim.frames[this.frame]
const sx = (frameIndex % 16) * this.frameWidth
const sy = Math.floor(frameIndex / 16) * this.frameHeight
ctx.save()
if (flipX) {
ctx.scale(-1, 1)
x = -x - this.frameWidth
}
ctx.drawImage(
this.image,
sx, sy, this.frameWidth, this.frameHeight,
Math.round(x), Math.round(y), this.frameWidth, this.frameHeight
)
ctx.restore()
}
}
// Usage
const player = new PixelSprite(spriteSheet, 16, 16)
player.addAnimation('idle', [0, 1], 2)
player.addAnimation('walk', [2, 3, 4, 5], 8)
player.addAnimation('jump', [6], 1)6) Troca de paleta
Uma técnica clássica para recolorir sprites:
js
function createPaletteSwap(image, oldColors, newColors) {
const canvas = document.createElement('canvas')
canvas.width = image.width
canvas.height = image.height
const ctx = canvas.getContext('2d')
ctx.drawImage(image, 0, 0)
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
const data = imageData.data
for (let i = 0; i < data.length; i += 4) {
const r = data[i], g = data[i + 1], b = data[i + 2]
for (let j = 0; j < oldColors.length; j++) {
const old = oldColors[j]
if (r === old.r && g === old.g && b === old.b) {
const newC = newColors[j]
data[i] = newC.r
data[i + 1] = newC.g
data[i + 2] = newC.b
break
}
}
}
ctx.putImageData(imageData, 0, 0)
return canvas
}
// Create enemy variant
const blueEnemy = createPaletteSwap(
enemySprite,
[{ r: 255, g: 0, b: 0 }], // Red
[{ r: 0, g: 100, b: 255 }] // Blue
)7) Renderização de mapas de tiles
js
class Tilemap {
constructor(tileSize, tilesetImage) {
this.tileSize = tileSize
this.tileset = tilesetImage
this.tilesPerRow = Math.floor(tilesetImage.width / tileSize)
this.data = []
}
setData(width, height, tiles) {
this.width = width
this.height = height
this.data = tiles
}
draw(ctx, cameraX = 0, cameraY = 0) {
const startCol = Math.floor(cameraX / this.tileSize)
const startRow = Math.floor(cameraY / this.tileSize)
const endCol = startCol + Math.ceil(ctx.canvas.width / this.tileSize) + 1
const endRow = startRow + Math.ceil(ctx.canvas.height / this.tileSize) + 1
for (let row = startRow; row < endRow; row++) {
for (let col = startCol; col < endCol; col++) {
if (row < 0 || row >= this.height || col < 0 || col >= this.width) continue
const tileIndex = this.data[row * this.width + col]
if (tileIndex < 0) continue
const sx = (tileIndex % this.tilesPerRow) * this.tileSize
const sy = Math.floor(tileIndex / this.tilesPerRow) * this.tileSize
const dx = Math.round(col * this.tileSize - cameraX)
const dy = Math.round(row * this.tileSize - cameraY)
ctx.drawImage(
this.tileset,
sx, sy, this.tileSize, this.tileSize,
dx, dy, this.tileSize, this.tileSize
)
}
}
}
}8) Efeitos de contorno
Adicione contornos aos sprites para melhorar a visibilidade:
js
function drawWithOutline(ctx, image, x, y, outlineColor = '#000') {
const offsets = [[-1, 0], [1, 0], [0, -1], [0, 1]]
// Draw outline
ctx.globalCompositeOperation = 'source-over'
for (const [ox, oy] of offsets) {
ctx.drawImage(image, Math.round(x + ox), Math.round(y + oy))
}
// Apply color to outline
ctx.globalCompositeOperation = 'source-in'
ctx.fillStyle = outlineColor
ctx.fillRect(x - 1, y - 1, image.width + 2, image.height + 2)
// Draw sprite on top
ctx.globalCompositeOperation = 'source-over'
ctx.drawImage(image, Math.round(x), Math.round(y))
}9) Tremor de tela
Um efeito clássico para melhorar a sensação do jogo:
js
const camera = {
x: 0,
y: 0,
shakeIntensity: 0,
shakeDuration: 0,
shake(intensity, duration) {
this.shakeIntensity = intensity
this.shakeDuration = duration
},
update(dt) {
if (this.shakeDuration > 0) {
this.shakeDuration -= dt
}
},
getOffset() {
if (this.shakeDuration <= 0) return { x: 0, y: 0 }
return {
x: Math.round((Math.random() - 0.5) * 2 * this.shakeIntensity),
y: Math.round((Math.random() - 0.5) * 2 * this.shakeIntensity),
}
}
}
// In render:
function render() {
const shake = camera.getOffset()
ctx.save()
ctx.translate(shake.x, shake.y)
// Draw everything...
ctx.restore()
}
// On hit:
camera.shake(4, 0.2) // 4 pixel intensity, 0.2 seconds10) Limitações da paleta de cores
Pixel art autêntica usa paletas limitadas:
js
const PALETTES = {
gameboy: ['#0f380f', '#306230', '#8bac0f', '#9bbc0f'],
nes: ['#000000', '#fcfcfc', '#f8f8f8', '#bcbcbc', /* ... */],
pico8: [
'#000000', '#1d2b53', '#7e2553', '#008751',
'#ab5236', '#5f574f', '#c2c3c7', '#fff1e8',
'#ff004d', '#ffa300', '#ffec27', '#00e436',
'#29adff', '#83769c', '#ff77a8', '#ffccaa'
],
}
function quantizeColor(r, g, b, palette) {
let minDist = Infinity
let closest = palette[0]
for (const color of palette) {
const pr = parseInt(color.slice(1, 3), 16)
const pg = parseInt(color.slice(3, 5), 16)
const pb = parseInt(color.slice(5, 7), 16)
const dist = (r - pr) ** 2 + (g - pg) ** 2 + (b - pb) ** 2
if (dist < minDist) {
minDist = dist
closest = color
}
}
return closest
}Conteúdo relacionado
- Loop de jogo com Canvas 2D
- Canvas responsivo para jogos
- Carregamento de assets por streaming
- Onde encontrar assets gratuitos para jogos — sprite sheets, pacotes de pixel art e fontes de assets 2D
- Renderização de texto em jogos — fontes bitmap que combinam com o estilo da sua pixel art