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 { getBotColors } from './palette'
import type { SpriteCustomization } from './index'
@@ -7,8 +7,9 @@ type PoseKey = keyof typeof ANIMATIONS
/**
* Generate a high-quality poster frame for a single bot.
* Takes the normal 96x96 sprite frame and renders it to a large canvas
* with pixel-art-aware enhancements: 3D bevel, glow outline, energy aura, sparkles.
* Takes the normal 96x96 sprite frame and upscales it with pixel-art-aware
* enhancements: visible pixel grid, 3D bevel, emissive glow, scan lines,
* cross-hatch background, energy aura, and tier-gated particles.
*/
export async function generatePosterFrame(
seed: string,
@@ -23,11 +24,9 @@ export async function generatePosterFrame(
// 1. Get the sprite sheet as a canvas
const sheetCanvas = await loadSpriteSheetCanvas(seed, tier, colors.primary, colors.secondary, archetype, customization)
const sheetCtx = sheetCanvas.getContext('2d')!
// 2. Extract a single mid-animation frame
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 srcX = frameIdx * FRAME_SIZE
const srcY = anim.row * FRAME_SIZE
@@ -52,7 +51,8 @@ export async function generatePosterFrame(
const scale = outputSize / FRAME_SIZE
// Helpers
// ═══ HELPERS ═══
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]
const i = (y * FRAME_SIZE + x) * 4
@@ -68,11 +68,53 @@ export async function generatePosterFrame(
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 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 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 dx = -1; dx <= 1; dx++) {
if (dx === 0 && dy === 0) continue
@@ -80,6 +122,15 @@ export async function generatePosterFrame(
}
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]
// 6. Find character bounding box for aura
// Character bounding box
let bMinX = FRAME_SIZE, bMaxX = 0, bMinY = FRAME_SIZE, bMaxY = 0
for (let y = 0; y < FRAME_SIZE; y++) {
for (let x = 0; x < FRAME_SIZE; x++) {
@@ -108,52 +159,70 @@ export async function generatePosterFrame(
const cy = ((bMinY + bMaxY) / 2) * scale
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 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.4, `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, ${auraIntensity * 0.4})`)
auraGrad.addColorStop(1, 'rgba(0,0,0,0)')
ctx.fillStyle = auraGrad
ctx.fillRect(0, 0, outputSize, outputSize)
// Second aura layer (tighter, brighter)
// Inner aura ring (tier 2+)
if (tier >= 2) {
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)')
ctx.fillStyle = innerGrad
ctx.fillRect(0, 0, outputSize, outputSize)
}
// Pass 2: Outer glow (wide, soft)
const outerR = Math.ceil(scale * 1.5)
// Pass 3: Outer glow (wide, soft)
const outerR = Math.ceil(scale * 2)
for (let y = 0; y < FRAME_SIZE; y++) {
for (let x = 0; x < FRAME_SIZE; x++) {
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.08)`
ctx.fillRect(dx - outerR, dy - outerR, scale + outerR * 2, scale + outerR * 2)
ctx.fillStyle = `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, 0.10)`
ctx.fillRect(x * scale - outerR, y * scale - 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)
for (let y = 0; y < FRAME_SIZE; y++) {
for (let x = 0; x < FRAME_SIZE; x++) {
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.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
const bevel = Math.max(1, Math.floor(scale / 5))
// Pass 5: Main pixel render with enhanced bevel + pixel grid
const bevel = Math.max(1, Math.ceil(scale / 3.5))
for (let y = 0; y < FRAME_SIZE; y++) {
for (let x = 0; x < FRAME_SIZE; x++) {
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.fillRect(dx, dy, scale, scale)
// 3D bevel on colored pixels (not outlines)
// Enhanced 3D bevel on colored pixels (not outlines)
if (!dark && scale >= 3) {
// Top edge highlight
ctx.fillStyle = 'rgba(255,255,255,0.14)'
// Top highlight
ctx.fillStyle = 'rgba(255,255,255,0.20)'
ctx.fillRect(dx, dy, scale, bevel)
// Left edge highlight
ctx.fillStyle = 'rgba(255,255,255,0.07)'
// Left highlight
ctx.fillStyle = 'rgba(255,255,255,0.10)'
ctx.fillRect(dx, dy + bevel, bevel, scale - bevel * 2)
// Bottom edge shadow
ctx.fillStyle = 'rgba(0,0,0,0.18)'
// Bottom shadow
ctx.fillStyle = 'rgba(0,0,0,0.25)'
ctx.fillRect(dx, dy + scale - bevel, scale, bevel)
// Right edge shadow
ctx.fillStyle = 'rgba(0,0,0,0.10)'
// Right shadow
ctx.fillStyle = 'rgba(0,0,0,0.15)'
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) {
ctx.fillStyle = `rgba(${gc[0]}, ${gc[1]}, ${gc[2]}, 0.06)`
ctx.fillRect(dx, dy, scale, scale)
}
}
}
// Pass 5: Specular highlights on large colored 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
// Only add specular if surrounded by similar pixels (interior of a region)
if (!edgeMap[y * FRAME_SIZE + x] && !isDark(x, y - 1) && !isDark(x, y + 1) && isOpaque(x - 1, y) && isOpaque(x + 1, y)) {
const dx = x * scale
const dy = y * scale
// Tiny specular dot in the upper-left of the pixel
ctx.fillStyle = 'rgba(255,255,255,0.06)'
ctx.fillRect(dx + 1, dy + 1, Math.ceil(scale / 3), Math.ceil(scale / 3))
// Pixel grid lines (only between adjacent opaque pixels)
ctx.fillStyle = 'rgba(0,0,0,0.10)'
if (isOpaque(x + 1, y)) {
ctx.fillRect(dx + scale - 1, dy, 1, scale) // right edge
}
if (isOpaque(x, y + 1)) {
ctx.fillRect(dx, dy + scale - 1, scale, 1) // bottom edge
}
// Logical pixel grid emphasis (every SCALE=2 source pixels = 1 logical pixel)
if (x % SCALE === SCALE - 1 && isOpaque(x + 1, y)) {
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) {
const count = 4 + tier * 2
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()
}
// ═══ 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)
}