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:
co-authored by
Claude Opus 4.6
parent
673961580c
commit
2d8cdcc60a
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user