feat: enhanced poster renderer with 10-pass pixel art pipeline

Adds emissive glow bloom, visible pixel grid, cross-hatch background,
scan lines, 3D bevel, and tier-gated energy particles to poster sprites.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 10:47:40 +00:00
co-authored by Claude Opus 4.6
parent 673961580c
commit 2d8cdcc60a
+201 -51
View File
@@ -1,4 +1,4 @@
import { FRAME_SIZE, ANIMATIONS } from './constants' import { FRAME_SIZE, SCALE, ANIMATIONS } from './constants'
import { loadSpriteSheetCanvas } from './index' import { loadSpriteSheetCanvas } from './index'
import { getBotColors } from './palette' import { getBotColors } from './palette'
import type { SpriteCustomization } from './index' import type { SpriteCustomization } from './index'
@@ -7,8 +7,9 @@ type PoseKey = keyof typeof ANIMATIONS
/** /**
* Generate a high-quality poster frame for a single bot. * Generate a high-quality poster frame for a single bot.
* Takes the normal 96x96 sprite frame and renders it to a large canvas * Takes the normal 96x96 sprite frame and upscales it with pixel-art-aware
* with pixel-art-aware enhancements: 3D bevel, glow outline, energy aura, sparkles. * enhancements: visible pixel grid, 3D bevel, emissive glow, scan lines,
* cross-hatch background, energy aura, and tier-gated particles.
*/ */
export async function generatePosterFrame( export async function generatePosterFrame(
seed: string, seed: string,
@@ -23,11 +24,9 @@ export async function generatePosterFrame(
// 1. Get the sprite sheet as a canvas // 1. Get the sprite sheet as a canvas
const sheetCanvas = await loadSpriteSheetCanvas(seed, tier, colors.primary, colors.secondary, archetype, customization) const sheetCanvas = await loadSpriteSheetCanvas(seed, tier, colors.primary, colors.secondary, archetype, customization)
const sheetCtx = sheetCanvas.getContext('2d')!
// 2. Extract a single mid-animation frame // 2. Extract a single mid-animation frame
const anim = ANIMATIONS[pose] const anim = ANIMATIONS[pose]
// Use early frames to avoid arm/leg clipping at frame edges
const frameIdx = pose === 'attack' ? 1 : pose === 'kick' ? 1 : Math.min(Math.floor(anim.frames / 2), anim.frames - 1) const frameIdx = pose === 'attack' ? 1 : pose === 'kick' ? 1 : Math.min(Math.floor(anim.frames / 2), anim.frames - 1)
const srcX = frameIdx * FRAME_SIZE const srcX = frameIdx * FRAME_SIZE
const srcY = anim.row * FRAME_SIZE const srcY = anim.row * FRAME_SIZE
@@ -52,7 +51,8 @@ export async function generatePosterFrame(
const scale = outputSize / FRAME_SIZE const scale = outputSize / FRAME_SIZE
// Helpers // ═══ HELPERS ═══
function getPixel(x: number, y: number): [number, number, number, number] { function getPixel(x: number, y: number): [number, number, number, number] {
if (x < 0 || x >= FRAME_SIZE || y < 0 || y >= FRAME_SIZE) return [0, 0, 0, 0] if (x < 0 || x >= FRAME_SIZE || y < 0 || y >= FRAME_SIZE) return [0, 0, 0, 0]
const i = (y * FRAME_SIZE + x) * 4 const i = (y * FRAME_SIZE + x) * 4
@@ -68,11 +68,53 @@ export async function generatePosterFrame(
return a > 20 && r < 30 && g < 30 && b < 30 return a > 20 && r < 30 && g < 30 && b < 30
} }
// 5. Build edge map (silhouette border) function pixelBrightness(r: number, g: number, b: number): number {
return (r * 0.299 + g * 0.587 + b * 0.114) / 255
}
function pixelSaturation(r: number, g: number, b: number): number {
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
if (max === 0) return 0
return (max - min) / max
}
// Known emissive colors (eyes, antenna tips, energy effects)
const EMISSIVE_COLORS = new Set([
'0,255,65', '0,255,0', '0,204,51', // green eyes
'255,255,0', '255,170,0', '255,204,0', // yellow glow
'0,238,255', '68,255,255', '136,255,255', // cyan energy
'255,68,0', '255,102,0', // fire
'255,255,255', // white sparkle
])
function isEmissive(r: number, g: number, b: number, a: number): boolean {
if (a < 20) return false
// Check known glow colors
if (EMISSIVE_COLORS.has(`${r},${g},${b}`)) return true
// High brightness + high saturation = emissive
const bright = pixelBrightness(r, g, b)
const sat = pixelSaturation(r, g, b)
if (bright > 0.7 && sat > 0.6) return true
return false
}
// ═══ BUILD MAPS ═══
// Edge map + emissive map in one pass
const edgeMap = new Uint8Array(FRAME_SIZE * FRAME_SIZE) const edgeMap = new Uint8Array(FRAME_SIZE * FRAME_SIZE)
const emissiveMap = new Uint8Array(FRAME_SIZE * FRAME_SIZE)
// Get the bot's primary/secondary colors to exclude from emissive detection
const primaryRGB = parseColorToRGB(colors.primary)
const secondaryRGB = parseColorToRGB(colors.secondary)
for (let y = 0; y < FRAME_SIZE; y++) { for (let y = 0; y < FRAME_SIZE; y++) {
for (let x = 0; x < FRAME_SIZE; x++) { for (let x = 0; x < FRAME_SIZE; x++) {
if (!isOpaque(x, y)) continue const [r, g, b, a] = getPixel(x, y)
if (a < 20) continue
// Edge detection
for (let dy = -1; dy <= 1; dy++) { for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) { for (let dx = -1; dx <= 1; dx++) {
if (dx === 0 && dy === 0) continue if (dx === 0 && dy === 0) continue
@@ -80,6 +122,15 @@ export async function generatePosterFrame(
} }
if (edgeMap[y * FRAME_SIZE + x]) break if (edgeMap[y * FRAME_SIZE + x]) break
} }
// Emissive detection (exclude primary/secondary body colors)
if (isEmissive(r, g, b, a)) {
const distPrimary = colorDistance(r, g, b, primaryRGB)
const distSecondary = colorDistance(r, g, b, secondaryRGB)
if (distPrimary > 80 && distSecondary > 80) {
emissiveMap[y * FRAME_SIZE + x] = 1
}
}
} }
} }
@@ -92,7 +143,7 @@ export async function generatePosterFrame(
} }
const gc = GLOW[glowColor] const gc = GLOW[glowColor]
// 6. Find character bounding box for aura // Character bounding box
let bMinX = FRAME_SIZE, bMaxX = 0, bMinY = FRAME_SIZE, bMaxY = 0 let bMinX = FRAME_SIZE, bMaxX = 0, bMinY = FRAME_SIZE, bMaxY = 0
for (let y = 0; y < FRAME_SIZE; y++) { for (let y = 0; y < FRAME_SIZE; y++) {
for (let x = 0; x < FRAME_SIZE; x++) { for (let x = 0; x < FRAME_SIZE; x++) {
@@ -108,52 +159,70 @@ export async function generatePosterFrame(
const cy = ((bMinY + bMaxY) / 2) * scale const cy = ((bMinY + bMaxY) / 2) * scale
const charR = Math.max(bMaxX - bMinX, bMaxY - bMinY) * scale * 0.65 const charR = Math.max(bMaxX - bMinX, bMaxY - bMinY) * scale * 0.65
// ═══ RENDER PASSES ═══ // ═══════════════════════════════════════
// RENDER PASSES
// ═══════════════════════════════════════
// Pass 1: Background energy aura // Pass 0: Dark background fill
ctx.fillStyle = '#0a0812'
ctx.fillRect(0, 0, outputSize, outputSize)
// Pass 1: Cross-hatch background pattern
ctx.strokeStyle = 'rgba(255, 255, 255, 0.035)'
ctx.lineWidth = 1
const hatchSpacing = 24
// Diagonal lines at 45 degrees
for (let i = -outputSize; i < outputSize * 2; i += hatchSpacing) {
ctx.beginPath()
ctx.moveTo(i, 0)
ctx.lineTo(i + outputSize, outputSize)
ctx.stroke()
ctx.beginPath()
ctx.moveTo(i + outputSize, 0)
ctx.lineTo(i, outputSize)
ctx.stroke()
}
// Pass 2: Background energy aura
const auraIntensity = tier >= 3 ? 0.25 : tier >= 1 ? 0.14 : 0.07
const auraGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, charR) const auraGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, charR)
const auraIntensity = tier >= 3 ? 0.2 : tier >= 1 ? 0.1 : 0.05
auraGrad.addColorStop(0, `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, ${auraIntensity})`) auraGrad.addColorStop(0, `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, ${auraIntensity})`)
auraGrad.addColorStop(0.4, `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, ${auraIntensity * 0.4})`) auraGrad.addColorStop(0.4, `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, ${auraIntensity * 0.4})`)
auraGrad.addColorStop(1, 'rgba(0,0,0,0)') auraGrad.addColorStop(1, 'rgba(0,0,0,0)')
ctx.fillStyle = auraGrad ctx.fillStyle = auraGrad
ctx.fillRect(0, 0, outputSize, outputSize) ctx.fillRect(0, 0, outputSize, outputSize)
// Second aura layer (tighter, brighter) // Inner aura ring (tier 2+)
if (tier >= 2) { if (tier >= 2) {
const innerGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, charR * 0.5) const innerGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, charR * 0.5)
innerGrad.addColorStop(0, `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, 0.08)`) innerGrad.addColorStop(0, `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, 0.1)`)
innerGrad.addColorStop(1, 'rgba(0,0,0,0)') innerGrad.addColorStop(1, 'rgba(0,0,0,0)')
ctx.fillStyle = innerGrad ctx.fillStyle = innerGrad
ctx.fillRect(0, 0, outputSize, outputSize) ctx.fillRect(0, 0, outputSize, outputSize)
} }
// Pass 2: Outer glow (wide, soft) // Pass 3: Outer glow (wide, soft)
const outerR = Math.ceil(scale * 1.5) const outerR = Math.ceil(scale * 2)
for (let y = 0; y < FRAME_SIZE; y++) { for (let y = 0; y < FRAME_SIZE; y++) {
for (let x = 0; x < FRAME_SIZE; x++) { for (let x = 0; x < FRAME_SIZE; x++) {
if (!edgeMap[y * FRAME_SIZE + x]) continue if (!edgeMap[y * FRAME_SIZE + x]) continue
const dx = x * scale ctx.fillStyle = `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, 0.10)`
const dy = y * scale ctx.fillRect(x * scale - outerR, y * scale - outerR, scale + outerR * 2, scale + outerR * 2)
ctx.fillStyle = `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, 0.08)`
ctx.fillRect(dx - outerR, dy - outerR, scale + outerR * 2, scale + outerR * 2)
} }
} }
// Pass 3: Inner glow (tight, brighter) // Pass 4: Inner glow (tight, brighter)
const innerR = Math.ceil(scale * 0.6) const innerR = Math.ceil(scale * 0.6)
for (let y = 0; y < FRAME_SIZE; y++) { for (let y = 0; y < FRAME_SIZE; y++) {
for (let x = 0; x < FRAME_SIZE; x++) { for (let x = 0; x < FRAME_SIZE; x++) {
if (!edgeMap[y * FRAME_SIZE + x]) continue if (!edgeMap[y * FRAME_SIZE + x]) continue
const dx = x * scale
const dy = y * scale
ctx.fillStyle = `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, 0.2)` ctx.fillStyle = `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, 0.2)`
ctx.fillRect(dx - innerR, dy - innerR, scale + innerR * 2, scale + innerR * 2) ctx.fillRect(x * scale - innerR, y * scale - innerR, scale + innerR * 2, scale + innerR * 2)
} }
} }
// Pass 4: Main pixel render with bevel // Pass 5: Main pixel render with enhanced bevel + pixel grid
const bevel = Math.max(1, Math.floor(scale / 5)) const bevel = Math.max(1, Math.ceil(scale / 3.5))
for (let y = 0; y < FRAME_SIZE; y++) { for (let y = 0; y < FRAME_SIZE; y++) {
for (let x = 0; x < FRAME_SIZE; x++) { for (let x = 0; x < FRAME_SIZE; x++) {
const [r, g, b, a] = getPixel(x, y) const [r, g, b, a] = getPixel(x, y)
@@ -167,47 +236,101 @@ export async function generatePosterFrame(
ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${a / 255})` ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${a / 255})`
ctx.fillRect(dx, dy, scale, scale) ctx.fillRect(dx, dy, scale, scale)
// 3D bevel on colored pixels (not outlines) // Enhanced 3D bevel on colored pixels (not outlines)
if (!dark && scale >= 3) { if (!dark && scale >= 3) {
// Top edge highlight // Top highlight
ctx.fillStyle = 'rgba(255,255,255,0.14)' ctx.fillStyle = 'rgba(255,255,255,0.20)'
ctx.fillRect(dx, dy, scale, bevel) ctx.fillRect(dx, dy, scale, bevel)
// Left edge highlight // Left highlight
ctx.fillStyle = 'rgba(255,255,255,0.07)' ctx.fillStyle = 'rgba(255,255,255,0.10)'
ctx.fillRect(dx, dy + bevel, bevel, scale - bevel * 2) ctx.fillRect(dx, dy + bevel, bevel, scale - bevel * 2)
// Bottom edge shadow // Bottom shadow
ctx.fillStyle = 'rgba(0,0,0,0.18)' ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.fillRect(dx, dy + scale - bevel, scale, bevel) ctx.fillRect(dx, dy + scale - bevel, scale, bevel)
// Right edge shadow // Right shadow
ctx.fillStyle = 'rgba(0,0,0,0.10)' ctx.fillStyle = 'rgba(0,0,0,0.15)'
ctx.fillRect(dx + scale - bevel, dy + bevel, bevel, scale - bevel * 2) ctx.fillRect(dx + scale - bevel, dy + bevel, bevel, scale - bevel * 2)
} }
// Subtle glow tint on edge pixels // Glow tint on edge pixels
if (edgeMap[y * FRAME_SIZE + x] && !dark) { if (edgeMap[y * FRAME_SIZE + x] && !dark) {
ctx.fillStyle = `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, 0.06)` ctx.fillStyle = `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, 0.06)`
ctx.fillRect(dx, dy, scale, scale) ctx.fillRect(dx, dy, scale, scale)
} }
}
}
// Pass 5: Specular highlights on large colored regions // Pixel grid lines (only between adjacent opaque pixels)
for (let y = 1; y < FRAME_SIZE - 1; y++) { ctx.fillStyle = 'rgba(0,0,0,0.10)'
for (let x = 1; x < FRAME_SIZE - 1; x++) { if (isOpaque(x + 1, y)) {
const [r, g, b, a] = getPixel(x, y) ctx.fillRect(dx + scale - 1, dy, 1, scale) // right edge
if (a < 20 || isDark(x, y)) continue }
// Only add specular if surrounded by similar pixels (interior of a region) if (isOpaque(x, y + 1)) {
if (!edgeMap[y * FRAME_SIZE + x] && !isDark(x, y - 1) && !isDark(x, y + 1) && isOpaque(x - 1, y) && isOpaque(x + 1, y)) { ctx.fillRect(dx, dy + scale - 1, scale, 1) // bottom edge
const dx = x * scale }
const dy = y * scale
// Tiny specular dot in the upper-left of the pixel // Logical pixel grid emphasis (every SCALE=2 source pixels = 1 logical pixel)
ctx.fillStyle = 'rgba(255,255,255,0.06)' if (x % SCALE === SCALE - 1 && isOpaque(x + 1, y)) {
ctx.fillRect(dx + 1, dy + 1, Math.ceil(scale / 3), Math.ceil(scale / 3)) ctx.fillStyle = 'rgba(0,0,0,0.06)'
ctx.fillRect(dx + scale - 1, dy, 1, scale)
}
if (y % SCALE === SCALE - 1 && isOpaque(x, y + 1)) {
ctx.fillStyle = 'rgba(0,0,0,0.06)'
ctx.fillRect(dx, dy + scale - 1, scale, 1)
} }
} }
} }
// Pass 6: Tier-gated energy particles // Pass 6: Emissive pixel glow (bloom effect)
ctx.globalCompositeOperation = 'lighter'
for (let y = 0; y < FRAME_SIZE; y++) {
for (let x = 0; x < FRAME_SIZE; x++) {
if (!emissiveMap[y * FRAME_SIZE + x]) continue
const [r, g, b] = getPixel(x, y)
const dx = x * scale + scale / 2
const dy = y * scale + scale / 2
// 3-ring bloom outward from pixel center
const rings = [
{ r: scale * 1.2, a: 0.15 },
{ r: scale * 2.2, a: 0.07 },
{ r: scale * 3.5, a: 0.03 },
]
for (const ring of rings) {
const grad = ctx.createRadialGradient(dx, dy, 0, dx, dy, ring.r)
grad.addColorStop(0, `rgba(${r}, ${g}, ${b}, ${ring.a})`)
grad.addColorStop(1, 'rgba(0,0,0,0)')
ctx.fillStyle = grad
ctx.fillRect(dx - ring.r, dy - ring.r, ring.r * 2, ring.r * 2)
}
}
}
ctx.globalCompositeOperation = 'source-over'
// Pass 7: Specular highlights on interior regions
for (let y = 1; y < FRAME_SIZE - 1; y++) {
for (let x = 1; x < FRAME_SIZE - 1; x++) {
const [r, g, b, a] = getPixel(x, y)
if (a < 20 || isDark(x, y)) continue
if (!edgeMap[y * FRAME_SIZE + x] && !isDark(x, y - 1) && !isDark(x, y + 1) && isOpaque(x - 1, y) && isOpaque(x + 1, y)) {
const specSize = Math.ceil(scale / 2.5)
ctx.fillStyle = 'rgba(255,255,255,0.08)'
ctx.fillRect(x * scale + 1, y * scale + 1, specSize, specSize)
}
}
}
// Pass 8: Horizontal scan lines
for (let sy = 0; sy < outputSize; sy += 2) {
// Check if this scanline row passes through a dark "screen" region
const srcY = Math.floor(sy / scale)
let isScreenRow = false
for (let sx = bMinX; sx <= bMaxX; sx++) {
if (isDark(sx, srcY)) { isScreenRow = true; break }
}
ctx.fillStyle = isScreenRow ? 'rgba(0,0,0,0.12)' : 'rgba(0,0,0,0.04)'
ctx.fillRect(0, sy, outputSize, 1)
}
// Pass 9: Tier-gated energy particles
if (tier >= 3) { if (tier >= 3) {
const count = 4 + tier * 2 const count = 4 + tier * 2
const seedHash = Array.from(seed).reduce((h, c) => (h * 31 + c.charCodeAt(0)) | 0, 0) const seedHash = Array.from(seed).reduce((h, c) => (h * 31 + c.charCodeAt(0)) | 0, 0)
@@ -232,3 +355,30 @@ export async function generatePosterFrame(
return out.toDataURL() return out.toDataURL()
} }
// ═══ UTILITY ═══
function parseColorToRGB(color: string): [number, number, number] {
const m = color.match(/hsl\((\d+),\s*(\d+)%,\s*(\d+)%\)/)
if (!m) return [128, 128, 128]
const h = +m[1] / 360, s = +m[2] / 100, l = +m[3] / 100
if (s === 0) { const v = Math.round(l * 255); return [v, v, v] }
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1; if (t > 1) t -= 1
if (t < 1 / 6) return p + (q - p) * 6 * t
if (t < 1 / 2) return q
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6
return p
}
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
const p = 2 * l - q
return [
Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
Math.round(hue2rgb(p, q, h) * 255),
Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
]
}
function colorDistance(r: number, g: number, b: number, ref: [number, number, number]): number {
return Math.sqrt((r - ref[0]) ** 2 + (g - ref[1]) ** 2 + (b - ref[2]) ** 2)
}