Pixel-Art-Rendering in Web-Spielen
Pixel-Art sieht großartig aus, wenn sie richtig gerendert wird, und schrecklich, wenn sie verschwimmt. Dieses Tutorial zeigt die Techniken für knackige, authentische Pixelgrafik.
1) Das Problem mit der Unschärfe
Standardmäßig glätten Browser Bilder beim Skalieren. Für Pixel-Art ist das falsch:
css
/* CSS solution */
canvas {
image-rendering: pixelated;
image-rendering: crisp-edges; /* Firefox fallback */
}2) Einstellungen am Canvas-Kontext
js
const canvas = document.getElementById('game')
const ctx = canvas.getContext('2d')
// Disable smoothing for crisp pixels
ctx.imageSmoothingEnabled = falseDas muss nach jeder Größenänderung des Canvas und jedem Kontextwechsel neu gesetzt werden.
3) Ganzzahlige Skalierung
Pixel-Art sieht bei ganzzahligen Skalierungen am besten aus (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) Sprites an ganzzahligen Positionen zeichnen
Subpixel-Positionen erzeugen Unschärfe:
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) Animation mit Sprite-Sheets
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) Palettentausch
Klassische Technik zum Umfärben von 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) Tilemaps rendern
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) Umriss-Effekte
Umrisse machen Sprites besser sichtbar:
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) Screenshake
Klassischer Effekt fürs Spielgefühl:
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) Beschränkte Farbpaletten
Authentische Pixel-Art nutzt begrenzte Paletten:
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
}Verwandt
- Canvas-2D-Spielschleife
- Responsives Spiel-Canvas
- Streaming von Assets
- Wo du kostenlose Game-Assets findest — Sprite-Sheets, Pixel-Art-Pakete und Quellen für 2D-Assets
- Textdarstellung in Spielen — Bitmap-Fonts, die zu deinem Pixel-Art-Stil passen