Rendu du pixel art dans les jeux web
Le pixel art est magnifique lorsqu’il est correctement affiché, mais désastreux lorsqu’il est flou. Ce tutoriel présente les techniques permettant d’obtenir des graphismes en pixels nets et authentiques.
1) Le problème du flou
Par défaut, les navigateurs lissent les images lors de leur mise à l’échelle. Pour le pixel art, ce comportement est inadapté :
/* Solution CSS */
canvas {
image-rendering: pixelated;
image-rendering: crisp-edges; /* Solution de repli pour Firefox */
}2) Paramètres du contexte Canvas
const canvas = document.getElementById('game')
const ctx = canvas.getContext('2d')
// Désactiver le lissage pour obtenir des pixels nets
ctx.imageSmoothingEnabled = falseCe paramètre doit être réappliqué après chaque redimensionnement du canvas ou changement de contexte.
3) Mise à l’échelle par nombres entiers
Le pixel art est plus réussi avec des facteurs d’échelle entiers (1x, 2x, 3x, 4x) :
const GAME_WIDTH = 160
const GAME_HEIGHT = 144 // Résolution de la Game Boy
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) Dessiner les sprites à des positions entières
Les positions sous-pixelliques provoquent du flou :
// Mauvais : provoque du flou
ctx.drawImage(sprite, 10.5, 20.3)
// Bon : pixels nets
ctx.drawImage(sprite, Math.floor(10.5), Math.floor(20.3))
// Ou arrondir à l’entier le plus proche
ctx.drawImage(sprite, Math.round(player.x), Math.round(player.y))5) Animation d’une feuille de sprites
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()
}
}
// Utilisation
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) Remplacement de palette
Une technique classique pour recolorer les sprites :
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
}
// Créer une variante de l’ennemi
const blueEnemy = createPaletteSwap(
enemySprite,
[{ r: 255, g: 0, b: 0 }], // Rouge
[{ r: 0, g: 100, b: 255 }] // Bleu
)7) Rendu d’une carte de tuiles
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) Effets de contour
Ajoutez des contours aux sprites pour améliorer leur visibilité :
function drawWithOutline(ctx, image, x, y, outlineColor = '#000') {
const offsets = [[-1, 0], [1, 0], [0, -1], [0, 1]]
// Dessiner le contour
ctx.globalCompositeOperation = 'source-over'
for (const [ox, oy] of offsets) {
ctx.drawImage(image, Math.round(x + ox), Math.round(y + oy))
}
// Appliquer la couleur au contour
ctx.globalCompositeOperation = 'source-in'
ctx.fillStyle = outlineColor
ctx.fillRect(x - 1, y - 1, image.width + 2, image.height + 2)
// Dessiner le sprite par-dessus
ctx.globalCompositeOperation = 'source-over'
ctx.drawImage(image, Math.round(x), Math.round(y))
}9) Tremblement de l’écran
Un effet classique pour renforcer les sensations de jeu :
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),
}
}
}
// Dans la fonction de rendu :
function render() {
const shake = camera.getOffset()
ctx.save()
ctx.translate(shake.x, shake.y)
// Tout dessiner...
ctx.restore()
}
// Lors d’un impact :
camera.shake(4, 0.2) // Intensité de 4 pixels pendant 0,2 seconde10) Contraintes de palette de couleurs
Le pixel art authentique utilise des palettes limitées :
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
}Articles connexes
- Boucle de jeu Canvas 2D
- Canvas de jeu responsive
- Chargement progressif des ressources
- Où trouver des ressources de jeu gratuites — feuilles de sprites, packs de pixel art et sources de ressources 2D
- Rendu du texte dans les jeux — polices bitmap adaptées au style de votre pixel art