网页游戏中的像素艺术渲染
像素艺术渲染对了惊艳,渲染错了就糊成一团。这篇教程涵盖让像素图形清晰、地道的技术。
1)模糊问题
浏览器默认在缩放图像时会做平滑。对像素艺术,这是错的:
css
/* CSS 方案 */
canvas {
image-rendering: pixelated;
image-rendering: crisp-edges; /* Firefox 兜底 */
}2)Canvas 上下文设置
js
const canvas = document.getElementById('game')
const ctx = canvas.getContext('2d')
// 禁用平滑,保持像素清晰
ctx.imageSmoothingEnabled = false每次 canvas resize 或上下文变化后都要重新设置。
3)整数倍缩放
像素艺术在整数倍缩放下最好看(1x、2x、3x、4x):
js
const GAME_WIDTH = 160
const GAME_HEIGHT = 144 // 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)在整数坐标绘制精灵
亚像素坐标会导致模糊:
js
// 不好:会模糊
ctx.drawImage(sprite, 10.5, 20.3)
// 好:像素清晰
ctx.drawImage(sprite, Math.floor(10.5), Math.floor(20.3))
// 或者四舍五入
ctx.drawImage(sprite, Math.round(player.x), Math.round(player.y))5)精灵表动画
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()
}
}
// 用法
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)调色板替换
给精灵换色的经典技巧:
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
}
// 创建敌人变体
const blueEnemy = createPaletteSwap(
enemySprite,
[{ r: 255, g: 0, b: 0 }], // 红色
[{ r: 0, g: 100, b: 255 }] // 蓝色
)7)瓦片地图渲染
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)描边效果
给精灵加描边,提升可读性:
js
function drawWithOutline(ctx, image, x, y, outlineColor = '#000') {
const offsets = [[-1, 0], [1, 0], [0, -1], [0, 1]]
// 绘制描边
ctx.globalCompositeOperation = 'source-over'
for (const [ox, oy] of offsets) {
ctx.drawImage(image, Math.round(x + ox), Math.round(y + oy))
}
// 给描边上色
ctx.globalCompositeOperation = 'source-in'
ctx.fillStyle = outlineColor
ctx.fillRect(x - 1, y - 1, image.width + 2, image.height + 2)
// 在上层画精灵本身
ctx.globalCompositeOperation = 'source-over'
ctx.drawImage(image, Math.round(x), Math.round(y))
}9)屏幕震动
经典手感效果:
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),
}
}
}
// 渲染中:
function render() {
const shake = camera.getOffset()
ctx.save()
ctx.translate(shake.x, shake.y)
// 绘制一切...
ctx.restore()
}
// 受击时:
camera.shake(4, 0.2) // 4 像素强度,0.2 秒10)调色板限制
地道的像素艺术使用受限调色板:
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
}相关阅读
- Canvas 2D 游戏循环
- 响应式游戏画布
- 流式资源加载
- 去哪里找免费游戏素材 —— 精灵表、像素艺术包和 2D 素材源
- 游戏中的文本渲染 —— 与你的像素艺术风格匹配的位图字体