Minor optimizations to sprite re-coloring

This commit is contained in:
2025-12-23 17:14:42 +08:00
parent c8efcfc83c
commit 83f145f490
4 changed files with 109 additions and 64 deletions

View File

@@ -1,7 +1,54 @@
use base64::{engine::GeneralPurpose, Engine};
use image::{Rgba, RgbaImage}; use image::{Rgba, RgbaImage};
use std::io::Cursor; use std::io::Cursor;
use std::sync::OnceLock;
const INPUT_GIF_BASE64: &str = include_str!("./neko.gif.txt"); const INPUT_GIF_BYTES: &[u8] = include_bytes!("./neko.gif");
const B64_ENGINE: GeneralPurpose = base64::engine::general_purpose::STANDARD;
struct GifFrame {
pixels: Vec<u8>,
delay: u16,
dispose: gif::DisposalMethod,
}
struct DecodedGif {
width: u16,
height: u16,
frames: Vec<GifFrame>,
}
static DECODED_GIF: OnceLock<DecodedGif> = OnceLock::new();
fn get_decoded_gif() -> &'static DecodedGif {
DECODED_GIF
.get_or_init(|| decode_gif_once(INPUT_GIF_BYTES).expect("Failed to decode embedded GIF"))
}
fn decode_gif_once(input_bytes: &[u8]) -> Result<DecodedGif, Box<dyn std::error::Error>> {
let reader = Cursor::new(input_bytes);
let mut decoder = gif::DecodeOptions::new();
decoder.set_color_output(gif::ColorOutput::RGBA);
let mut decoder = decoder.read_info(reader)?;
let width = decoder.width();
let height = decoder.height();
let mut frames = Vec::new();
while let Some(frame) = decoder.read_next_frame()? {
frames.push(GifFrame {
pixels: frame.buffer.to_vec(),
delay: frame.delay,
dispose: frame.dispose,
});
}
Ok(DecodedGif {
width,
height,
frames,
})
}
pub fn recolor_gif_base64( pub fn recolor_gif_base64(
white_color_hex: &str, white_color_hex: &str,
@@ -11,18 +58,19 @@ pub fn recolor_gif_base64(
let white_color = parse_hex_color(white_color_hex)?; let white_color = parse_hex_color(white_color_hex)?;
let black_color = parse_hex_color(black_color_hex)?; let black_color = parse_hex_color(black_color_hex)?;
// Decode base64 input // Get pre-decoded GIF data
let input_bytes = base64::decode(INPUT_GIF_BASE64.trim())?; let decoded_gif = get_decoded_gif();
// Process GIF // Process GIF with cached decoded frames
let output_bytes = recolor_gif_bytes(&input_bytes, white_color, black_color, apply_texture)?; let output_bytes = recolor_gif_frames(decoded_gif, white_color, black_color, apply_texture)?;
// Encode output to base64 // Encode output to base64
Ok(base64::encode(&output_bytes)) Ok(B64_ENGINE.encode(&output_bytes))
} }
#[inline]
fn parse_hex_color(hex: &str) -> Result<Rgba<u8>, Box<dyn std::error::Error>> { fn parse_hex_color(hex: &str) -> Result<Rgba<u8>, Box<dyn std::error::Error>> {
let hex = hex.trim_start_matches('#'); let hex = hex.strip_prefix('#').unwrap_or(hex);
if hex.len() != 6 { if hex.len() != 6 {
return Err("Hex color must be 6 characters".into()); return Err("Hex color must be 6 characters".into());
} }
@@ -34,38 +82,45 @@ fn parse_hex_color(hex: &str) -> Result<Rgba<u8>, Box<dyn std::error::Error>> {
Ok(Rgba([r, g, b, 255])) Ok(Rgba([r, g, b, 255]))
} }
fn recolor_gif_bytes( fn recolor_gif_frames(
input_bytes: &[u8], decoded_gif: &DecodedGif,
white_color: Rgba<u8>, white_color: Rgba<u8>,
black_color: Rgba<u8>, black_color: Rgba<u8>,
apply_texture: bool, apply_texture: bool,
) -> Result<Vec<u8>, Box<dyn std::error::Error>> { ) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
// Decode input GIF let width = decoded_gif.width;
let reader = Cursor::new(input_bytes); let height = decoded_gif.height;
let mut decoder = gif::DecodeOptions::new();
decoder.set_color_output(gif::ColorOutput::RGBA);
let mut decoder = decoder.read_info(reader)?;
// Get GIF properties // Pre-allocate output buffer
let width = decoder.width(); let estimated_capacity = INPUT_GIF_BYTES.len() * 2;
let height = decoder.height(); let output_buffer = Vec::with_capacity(estimated_capacity);
// Prepare output encoder with in-memory buffer
let output_buffer = Vec::new();
let writer = Cursor::new(output_buffer); let writer = Cursor::new(output_buffer);
let mut encoder = gif::Encoder::new(writer, width, height, &[])?; let mut encoder = gif::Encoder::new(writer, width, height, &[])?;
encoder.set_repeat(gif::Repeat::Infinite)?; encoder.set_repeat(gif::Repeat::Infinite)?;
// Process each frame // Pre-generate palettes once
while let Some(frame) = decoder.read_next_frame()? { let white_palette = if apply_texture {
// Convert frame buffer to RgbaImage generate_color_palette(white_color, 7)
let mut img = RgbaImage::from_raw(width as u32, height as u32, frame.buffer.to_vec()) } else {
vec![white_color]
};
let black_palette = if apply_texture {
generate_color_palette(black_color, 7)
} else {
vec![black_color]
};
// Process each pre-decoded frame
for frame in &decoded_gif.frames {
// Create image from cached pixel data
let mut img = RgbaImage::from_raw(width as u32, height as u32, frame.pixels.clone())
.ok_or("Failed to create image from frame")?; .ok_or("Failed to create image from frame")?;
// Recolor the image // Recolor the image
recolor_image(&mut img, white_color, black_color, apply_texture); recolor_image(&mut img, &white_palette, &black_palette, apply_texture);
// Create output frame with same timing // Create output frame
let mut pixel_data = img.into_raw(); let mut pixel_data = img.into_raw();
let mut output_frame = gif::Frame::from_rgba_speed(width, height, &mut pixel_data, 10); let mut output_frame = gif::Frame::from_rgba_speed(width, height, &mut pixel_data, 10);
output_frame.delay = frame.delay; output_frame.delay = frame.delay;
@@ -81,22 +136,13 @@ fn recolor_gif_bytes(
fn recolor_image( fn recolor_image(
img: &mut RgbaImage, img: &mut RgbaImage,
white_color: Rgba<u8>, white_palette: &[Rgba<u8>],
black_color: Rgba<u8>, black_palette: &[Rgba<u8>],
apply_texture: bool, apply_texture: bool,
) { ) {
// Generate color palettes with variations if texture is enabled // Pre-compute values for hot path
let white_palette = if apply_texture { let white_single = white_palette[0];
generate_color_palette(white_color, 7) let black_single = black_palette[0];
} else {
vec![white_color]
};
let black_palette = if apply_texture {
generate_color_palette(black_color, 7)
} else {
vec![black_color]
};
for (x, y, pixel) in img.enumerate_pixels_mut() { for (x, y, pixel) in img.enumerate_pixels_mut() {
let Rgba([r, g, b, a]) = *pixel; let Rgba([r, g, b, a]) = *pixel;
@@ -106,44 +152,42 @@ fn recolor_image(
continue; continue;
} }
// Calculate brightness (0-255) // Calculate brightness using faster bit shift approximation
let brightness = (r as u16 + g as u16 + b as u16) / 3; let brightness = ((r as u16 + g as u16 + b as u16) * 85) >> 8;
// Determine pixel type based on brightness // Determine pixel type based on brightness
if brightness < 64 { if brightness < 64 {
// Very dark pixels (outlines) - apply black target color // Very dark pixels (outlines) - apply black target color
let chosen_color = if apply_texture { let chosen_color = if apply_texture {
pick_color_from_palette(&black_palette, x, y) pick_color_from_palette(black_palette, x, y)
} else { } else {
black_color black_single
}; };
*pixel = chosen_color; *pixel = chosen_color;
} else if brightness > 200 { } else if brightness > 200 {
// White/light pixels (body) - apply white target color // White/light pixels (body) - apply white target color
let chosen_color = if apply_texture { let chosen_color = if apply_texture {
pick_color_from_palette(&white_palette, x, y) pick_color_from_palette(white_palette, x, y)
} else { } else {
white_color white_single
}; };
*pixel = chosen_color; *pixel = chosen_color;
} else { } else {
// Gray pixels (anti-aliasing/shadows) - blend with target color // Gray pixels (anti-aliasing/shadows) - blend with target color
// Blend factor based on how gray it is (0.0 = keep original, 1.0 = full target)
let blend_factor = (brightness as f32 - 64.0) / (200.0 - 64.0);
let blend_factor = blend_factor.clamp(0.0, 1.0);
let chosen_color = if apply_texture { let chosen_color = if apply_texture {
pick_color_from_palette(&white_palette, x, y) pick_color_from_palette(white_palette, x, y)
} else { } else {
white_color white_single
}; };
let new_r = // Use integer arithmetic instead of floating point
(r as f32 * (1.0 - blend_factor) + chosen_color[0] as f32 * blend_factor) as u8; let blend_scaled = ((brightness as u32 - 64) * 256) / 136;
let new_g = let blend_scaled = blend_scaled.min(256) as u16;
(g as f32 * (1.0 - blend_factor) + chosen_color[1] as f32 * blend_factor) as u8; let inv_blend = 256 - blend_scaled;
let new_b =
(b as f32 * (1.0 - blend_factor) + chosen_color[2] as f32 * blend_factor) as u8; let new_r = ((r as u16 * inv_blend + chosen_color[0] as u16 * blend_scaled) >> 8) as u8;
let new_g = ((g as u16 * inv_blend + chosen_color[1] as u16 * blend_scaled) >> 8) as u8;
let new_b = ((b as u16 * inv_blend + chosen_color[2] as u16 * blend_scaled) >> 8) as u8;
*pixel = Rgba([new_r, new_g, new_b, a]); *pixel = Rgba([new_r, new_g, new_b, a]);
} }
@@ -155,7 +199,7 @@ fn generate_color_palette(base_color: Rgba<u8>, count: usize) -> Vec<Rgba<u8>> {
let Rgba([r, g, b, a]) = base_color; let Rgba([r, g, b, a]) = base_color;
// Generate darker and brighter variations // Generate darker and brighter variations
let variation_range = 25; // How much to vary (+/- this value) let variation_range = 25;
let step = (variation_range * 2) / (count - 1).max(1); let step = (variation_range * 2) / (count - 1).max(1);
for i in 0..count { for i in 0..count {
@@ -171,12 +215,14 @@ fn generate_color_palette(base_color: Rgba<u8>, count: usize) -> Vec<Rgba<u8>> {
palette palette
} }
#[inline(always)]
fn pick_color_from_palette(palette: &[Rgba<u8>], x: u32, y: u32) -> Rgba<u8> { fn pick_color_from_palette(palette: &[Rgba<u8>], x: u32, y: u32) -> Rgba<u8> {
// Use a deterministic "random" selection based on pixel position // Use a deterministic "random" selection based on pixel position
// This creates consistent noise pattern across frames
let hash = x let hash = x
.wrapping_mul(374761393) .wrapping_mul(374761393)
.wrapping_add(y.wrapping_mul(668265263)); .wrapping_add(y.wrapping_mul(668265263));
let index = (hash % palette.len() as u32) as usize; let index = (hash as usize) % palette.len();
palette[index]
// SAFETY: index is guaranteed to be < palette.len() due to modulo operation
unsafe { *palette.get_unchecked(index) }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -1 +0,0 @@
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

View File

@@ -106,7 +106,7 @@
if (debounceTimer) clearTimeout(debounceTimer); if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => { debounceTimer = setTimeout(() => {
generatePreview(); generatePreview();
}, 300); // Adjust debounce delay as needed (300ms is a common starting point) }, 100);
} }
function updateFrame() { function updateFrame() {