cool-bun-demo/image_converter.rb

436 lines
9.5 KiB
Ruby
Executable File

#!/usr/bin/env ruby
require 'rmagick'
# [ ] load the image
# [ ] map each pixel to a 12 bit color, with pixels less than 25% transparent as mask
# [ ] sort the color counts
# [ ] transparent pixels become the mask
# [ ] turn the big color counts into three bitplames worth of data
# [ ] turn the little color counts into up to 8 sprites worth of data strips & color changes
image = Magick::Image.read('topaz.png').first
rows = []
class MaskPixel
def initialize; end
def color? = false
def ==(other)
other.is_a?(MaskPixel)
end
end
class Color
attr_reader :red, :green, :blue
def initialize(red:, green:, blue:)
@red = red
@green = green
@blue = blue
end
WHITE = new(red: 15, green: 15, blue: 15)
BLACK = new(red: 0, green: 0, blue: 0)
TRANSPARENT = new(red: -1, green: -1, blue: -1)
def ==(other)
red == other.red && green == other.green && blue == other.blue
end
def transparent?
red == -1 && green == -1 && blue == -1
end
def -(other)
red - other.red + green - other.green + blue - other.blue
end
def for_diff
2 * red * red + 4 * green * green + 3 * blue * blue
end
alias eql? ==
def hash
red * 256 + green * 16 + blue
end
end
class BitplanePixel
attr_reader :color
def initialize(color:)
@color = color
end
def color? = true
end
MAX_ALPHA = Magick::QuantumRange * 0.25
BIT_SHIFT = Math.log2(Magick::QuantumRange + 1) - 4
class PixelRow
include Enumerable
def initialize
@row = []
end
def add(pixel:, x:)
@row[x] = pixel
end
def each(&block)
@row.each(&block)
end
def prioritized_colors
colors_with_usage.sort_by(&:last).reverse.map(&:first)
end
private
def colors_with_usage
last = nil
count = 0
@row.find_all(&:color?).each_with_object({}) do |pixel, obj|
if last != pixel.color
last = pixel.color
count = 1
end
count += count
obj[pixel.color] ||= 0
obj[pixel.color] += count
end
end
end
class RowColors
def initialize; end
end
class RowColorCalculator
MAX_BITPLANE_COLORS = 8
MAX_SPRITE_COLORS = 8
def self.calculate(row:)
prioritized_colors = row.prioritized_colors
bitplane_pixels_by_color = {
Color::TRANSPARENT => 0,
Color::BLACK => 1,
Color::WHITE => 2
}
sprite_pixels_by_color = {}
colors_by_bitplane_pixel = {}
colors_by_sprite_pixel = {}
prioritized_colors.each do |color|
next if bitplane_pixels_by_color.keys.include?(color)
closest_matches = bitplane_pixels_by_color.keys.reject(&:transparent?).map do |bp_color|
[(bp_color.for_diff - color.for_diff).abs, bp_color]
end.compact
closest_match = closest_matches.min_by(&:first)
if bitplane_pixels_by_color.values.max == MAX_BITPLANE_COLORS - 1
bitplane_pixels_by_color[color] = bitplane_pixels_by_color[closest_match.last]
max_sprite_value = sprite_pixels_by_color.values.max
if !max_sprite_value || max_sprite_value < MAX_SPRITE_COLORS - 1
new_pixel_index = colors_by_sprite_pixel.count
sprite_pixels_by_color[color] = new_pixel_index
colors_by_sprite_pixel[new_pixel_index] = color
end
else
new_pixel_index = colors_by_bitplane_pixel.count + 3
bitplane_pixels_by_color[color] = new_pixel_index
colors_by_bitplane_pixel[new_pixel_index] = color
end
end
{
bitplane_pixels_by_color:,
sprite_pixels_by_color:,
colors_by_bitplane_pixel:,
colors_by_sprite_pixel:
}
end
end
class RowMaskBitplaneCalculator
def self.calculate(row:)
bits = row.map { |p| p.is_a?(MaskPixel) ? 0 : 1 }
bits.each_slice(8).map do |pixels|
byte = 0
pixels.each_with_index do |pixel, index|
bit = 7 - index
byte |= (pixel << bit)
end
byte
end
end
end
class RowBitplaneCalculator
def self.calculate(row:, bitplane_pixels_by_color:)
output_pixels = row.map do |pixel|
next 0 if pixel.is_a?(MaskPixel)
bitplane_pixels_by_color[pixel.color] || 0
end
bitplanes = [[], [], []]
output_pixels.each_slice(8) do |pixels|
bits = [0] * 3
pixels.each_with_index do |pixel, index|
bit = 7 - index
3.times do |i|
bits[i] |= (((pixel >> i) & 1) << bit)
end
end
3.times do |i|
bitplanes[i] << bits[i]
end
end
bitplanes
end
end
class Sprite
attr_reader :x, :y, :color, :pixels
# @!attribute color
# @return Color
def initialize(x:, y:, color:)
@x = x
@y = y
@color = color
@pixels = [0] * 16
end
def add_pixel(x:, index:)
return if x - @x >= 16
@pixels[x - @x] = index
end
end
class RowSpriteCalculator
def self.calculate(y:, row:, sprite_pixels_by_color:)
sprites_by_color = {}
row.each_with_index.map do |pixel, x|
next if pixel.is_a?(MaskPixel)
sprite_pixel = sprite_pixels_by_color[pixel.color]
next unless sprite_pixel
sprite = sprites_by_color[pixel.color]
unless sprite
sprite = Sprite.new(y:, x:, color: pixel.color)
sprites_by_color[pixel.color] = sprite
end
sprite.add_pixel(x:, index: 1)
end
sprites_by_color
end
end
module Amiga
class Util
TOTAL_HEIGHT = 256
TOP_OFFSET = 44
LEFT_OFFSET = 128
SPRITE_HEIGHT = TOTAL_HEIGHT + TOP_OFFSET
def self.color_to_amiga_word(color:)
(color.blue + (color.green << 4) + (color.red << 8))
end
def self.sprposctl(x:, y:)
y += TOP_OFFSET
x += LEFT_OFFSET
sprpos = ((y & 0xff) << 8) +
((x & 0x1fe) >> 1)
sprctl = (((y + TOTAL_HEIGHT) & 0xff) << 8) +
((y & 0x100) >> 6) +
(((y + TOTAL_HEIGHT) & 0x100) >> 7) +
(x & 1)
{ sprpos:, sprctl: }
end
end
class BitplaneCopperlistFactory
def self.build(colors_by_bitplane_pixel:)
5.times.map do |color_index|
color = colors_by_bitplane_pixel[color_index + 3] || Color::BLACK
Amiga::Util.color_to_amiga_word(color:)
end.pack('n*')
end
end
class BitplaneDataFactory
def self.build(bitplanes:)
bitplanes.map do |data|
data.pack('C*')
end
end
end
class BitplaneMaskDataFactory
def self.build(bitplane:)
bitplane.pack('C*')
end
end
class SpriteCopperlistFactory
# @param sprites [Array<Sprite>]
def self.build(sprites:, y_offset:)
obj = []
8.times do |sprite_index|
color = 0
sprpos = 0
sprctl = 0
sprite = sprites[sprite_index]
if sprite
result = Amiga::Util.sprposctl(x: sprite.x, y: sprite.y + y_offset)
color = Amiga::Util.color_to_amiga_word(color: sprite.color)
sprpos = result[:sprpos]
sprctl = result[:sprctl]
end
obj << color
obj << sprpos
obj << sprctl
end
obj.pack('n*')
end
end
class SpriteDataFactory
def self.build(sprites:)
sprite_out = [''] * 8
sprites_with_bitplane = sprites.zip([0, 1] * 4)
8.times do |sprite_index|
sprite, bitplane = sprites_with_bitplane[sprite_index]
2.times do |current_bitplane|
result = 0
if bitplane == current_bitplane
sprite.pixels.each_with_index do |pixel, bit|
result |= (pixel << (15 - bit))
end
end
sprite_out[sprite_index] += [result].pack('n*')
end
end
sprite_out
end
end
end
image.each_pixel do |px, x, y|
rows[y] ||= PixelRow.new
pixel = if px.alpha < MAX_ALPHA
MaskPixel.new
else
BitplanePixel.new(
color: Color.new(
red: px.red >> BIT_SHIFT,
green: px.green >> BIT_SHIFT,
blue: px.blue >> BIT_SHIFT
)
)
end
rows[y].add(pixel:, x:)
end
copper_colors = ''
topaz_bitplanes = [''] * 3
sprite_copperlist = ''
sprite_data = [''] * 8
mask_bitplane = ''
8.times do |i|
result = Amiga::Util.sprposctl(x: 319, y: 0)
sprite_data[i] = [result[:sprpos], result[:sprctl]].pack('n*')
end
rows.each_with_index do |row, y_offset|
result = RowColorCalculator.calculate(row:)
bitplane_pixels_by_color = result[:bitplane_pixels_by_color]
sprite_pixels_by_color = result[:sprite_pixels_by_color]
colors_by_bitplane_pixel = result[:colors_by_bitplane_pixel]
bitplanes = RowBitplaneCalculator.calculate(row:, bitplane_pixels_by_color:)
sprites = RowSpriteCalculator.calculate(y: y_offset, row:, sprite_pixels_by_color:).values
mask = RowMaskBitplaneCalculator.calculate(row:)
mask_bitplane += Amiga::BitplaneMaskDataFactory.build(bitplane: mask)
copper_colors += Amiga::BitplaneCopperlistFactory.build(colors_by_bitplane_pixel:)
raw_bitplanes = Amiga::BitplaneDataFactory.build(bitplanes:)
sprite_copperlist += Amiga::SpriteCopperlistFactory.build(sprites:, y_offset:)
raw_sprite_data = Amiga::SpriteDataFactory.build(sprites:)
raw_bitplanes.each_with_index do |raw, index|
topaz_bitplanes[index] += raw
end
raw_sprite_data.each_with_index do |raw, index|
sprite_data[index] += raw
end
end
8.times do |i|
sprite_data[i] += [0, 0].pack('n*')
end
File.open('copper-colors', 'wb') { |fh| fh.print copper_colors }
File.open('topaz-bitplane', 'wb') { |fh| fh.print topaz_bitplanes.join('') }
File.open('mask-bitplane', 'wb') { |fh| fh.print mask_bitplane }
File.open('sprite-copperlist', 'wb') { |fh| fh.print sprite_copperlist }
File.open('sprite-data', 'wb') { |fh| fh.print sprite_data.join('') }