wip convert to grayscale/single-hue mode

doesn't seem to be hitting the right palette register
This commit is contained in:
Brooke Vibber 2024-08-23 21:27:24 -07:00
parent 77606d464e
commit 2b17930f0f
2 changed files with 42 additions and 551 deletions

View file

@ -203,270 +203,6 @@ class RGB {
} }
} }
/*
// snarfed from https://lospec.com/palette-list/atari-8-bit-family-gtia
// which was calculated with Retrospecs App's Atari 800 emulator
let atariRGB = [
0x000000,
0x111111,
0x222222,
0x333333,
0x444444,
0x555555,
0x666666,
0x777777,
0x888888,
0x999999,
0xaaaaaa,
0xbbbbbb,
0xcccccc,
0xdddddd,
0xeeeeee,
0xffffff,
0x190700,
0x2a1800,
0x3b2900,
0x4c3a00,
0x5d4b00,
0x6e5c00,
0x7f6d00,
0x907e09,
0xa18f1a,
0xb3a02b,
0xc3b13c,
0xd4c24d,
0xe5d35e,
0xf7e46f,
0xfff582,
0xffff96,
0x310000,
0x3f0000,
0x531700,
0x642800,
0x753900,
0x864a00,
0x975b0a,
0xa86c1b,
0xb97d2c,
0xca8e3d,
0xdb9f4e,
0xecb05f,
0xfdc170,
0xffd285,
0xffe39c,
0xfff4b2,
0x420404,
0x4f0000,
0x600800,
0x711900,
0x822a0d,
0x933b1e,
0xa44c2f,
0xb55d40,
0xc66e51,
0xd77f62,
0xe89073,
0xf9a183,
0xffb298,
0xffc3ae,
0xffd4c4,
0xffe5da,
0x410103,
0x50000f,
0x61001b,
0x720f2b,
0x83203c,
0x94314d,
0xa5425e,
0xb6536f,
0xc76480,
0xd87591,
0xe986a2,
0xfa97b3,
0xffa8c8,
0xffb9de,
0xffcaef,
0xfbdcf6,
0x330035,
0x440041,
0x55004c,
0x660c5c,
0x771d6d,
0x882e7e,
0x993f8f,
0xaa50a0,
0xbb61b1,
0xcc72c2,
0xdd83d3,
0xee94e4,
0xffa5e4,
0xffb6e9,
0xffc7ee,
0xffd8f3,
0x1d005c,
0x2e0068,
0x400074,
0x511084,
0x622195,
0x7332a6,
0x8443b7,
0x9554c8,
0xa665d9,
0xb776ea,
0xc887eb,
0xd998eb,
0xe9a9ec,
0xfbbaeb,
0xffcbef,
0xffdff9,
0x020071,
0x13007d,
0x240b8c,
0x351c9d,
0x462dae,
0x573ebf,
0x684fd0,
0x7960e1,
0x8a71f2,
0x9b82f7,
0xac93f7,
0xbda4f7,
0xceb5f7,
0xdfc6f7,
0xf0d7f7,
0xffe8f8,
0x000068,
0x000a7c,
0x081b90,
0x192ca1,
0x2a3db2,
0x3b4ec3,
0x4c5fd4,
0x5d70e5,
0x6e81f6,
0x7f92ff,
0x90a3ff,
0xa1b4ff,
0xb2c5ff,
0xc3d6ff,
0xd4e7ff,
0xe5f8ff,
0x000a4d,
0x001b63,
0x002c79,
0x023d8f,
0x134ea0,
0x245fb1,
0x3570c2,
0x4681d3,
0x5792e4,
0x68a3f5,
0x79b4ff,
0x8ac5ff,
0x9bd6ff,
0xace7ff,
0xbdf8ff,
0xceffff,
0x001a26,
0x002b3c,
0x003c52,
0x004d68,
0x065e7c,
0x176f8d,
0x28809e,
0x3991af,
0x4aa2c0,
0x5bb3d1,
0x6cc4e2,
0x7dd5f3,
0x8ee6ff,
0x9ff7ff,
0xb0ffff,
0xc1ffff,
0x01250a,
0x023610,
0x004622,
0x005738,
0x05684d,
0x16795e,
0x278a6f,
0x389b80,
0x49ac91,
0x5abda2,
0x6bceb3,
0x7cdfc4,
0x8df0d5,
0x9effe5,
0xaffff1,
0xc0fffd,
0x04260d,
0x043811,
0x054713,
0x005a1b,
0x106b1b,
0x217c2c,
0x328d3d,
0x439e4e,
0x54af5f,
0x65c070,
0x76d181,
0x87e292,
0x98f3a3,
0xa9ffb3,
0xbaffbf,
0xcbffcb,
0x00230a,
0x003510,
0x044613,
0x155613,
0x266713,
0x377813,
0x488914,
0x599a25,
0x6aab36,
0x7bbc47,
0x8ccd58,
0x9dde69,
0xaeef7a,
0xbfff8b,
0xd0ff97,
0xe1ffa3,
0x001707,
0x0e2808,
0x1f3908,
0x304a08,
0x415b08,
0x526c08,
0x637d08,
0x748e0d,
0x859f1e,
0x96b02f,
0xa7c140,
0xb8d251,
0xc9e362,
0xdaf473,
0xebff82,
0xfcff8e,
0x1b0701,
0x2c1801,
0x3c2900,
0x4d3b00,
0x5f4c00,
0x705e00,
0x816f00,
0x938009,
0xa4921a,
0xb2a02b,
0xc7b43d,
0xd8c64e,
0xead760,
0xf6e46f,
0xfffa84,
0xffff99,
].map((hex) => RGB.fromHex(hex).fromNTSC());
//].map((hex) => RGB.fromHex(hex));
*/
let atariRGB = []; let atariRGB = [];
for (let i = 0; i < 256; i++) { for (let i = 0; i < 256; i++) {
atariRGB[i] = RGB.fromGTIA(i); atariRGB[i] = RGB.fromGTIA(i);
@ -475,17 +211,12 @@ for (let i = 0; i < 256; i++) {
/** /**
* Dither RGB input data with a target palette size. * Dither RGB input data and pick a monochrome palette.
* If the number of used colors exceeds `n`, the
* palette will be reduced until it fits.
* @param {RGB[]} input source scanline data, in linear RGB * @param {RGB[]} input source scanline data, in linear RGB
* @param {number[]} palette - current working palette, as Atari 8-bit color values (low nybble luminance, high nybble hue)
* @param {number} n - target color count
* @param {number} y
* @returns {{output: number[], palette: number[], error: RGB[]}} * @returns {{output: number[], palette: number[], error: RGB[]}}
*/ */
function decimate(input, palette, n) { function colorize(input) {
let width = input.length; let width = input.length;
let inputPixel = (x, error) => { let inputPixel = (x, error) => {
@ -508,8 +239,7 @@ function decimate(input, palette, n) {
} }
let output = zeroes(width); let output = zeroes(width);
let popularity = zeroes(palette.length); let distance = 0;
let distance2 = 0;
let nextError = new RGB(0, 0, 0); let nextError = new RGB(0, 0, 0);
@ -533,7 +263,7 @@ function decimate(input, palette, n) {
} }
output[x] = pick; output[x] = pick;
popularity[pick]++; distance += shortest;
let share = (n) => nextError.multiply(n / 16); let share = (n) => nextError.multiply(n / 16);
@ -545,228 +275,23 @@ function decimate(input, palette, n) {
return { return {
output, output,
palette, palette,
distance2, distance,
popularity,
error: error.next error: error.next
}; };
}; };
let best = null;
let decimated = palette.slice(); for (let hue = 0; hue < 16; hue++) {
let palette = [];
// force to grayscale for (let i = 0; i < 16; i++) {
//decimated = [0, 5, 10, 15]; palette[i] = (hue << 4) | i;
}
// force to rgb let variant = dither(palette);
//decimated = [0, 0x36, 0xb6, 0x86]; if (!best || variant.distance < best.distance) {
best = variant;
// force to rWb }
//decimated = [0, 0x36, 0x0f, 0x86];
let reserved = [0]; // black
//reserved = [0, 15]; // black, white
//reserved = [0, 5, 10, 15]; // grayscale
//reserved = [0, 0x48, 0x78, 15]; // vaporwave
//reserved = [0, 0x3c, 0x78, 15]; // red/blue/white
/*
if (( y & 1 ) === 0) {
reserved = [0, 0x3c, 0x1e, 15]; // red/yellow/white
} else {
reserved = [0, 0x76, 0x9e, 0xb8]; // blue/cyan/green
} }
*/ return best;
let keepers = zeroes(256);
for (let i of reserved) {
keepers[i & 0xfe] = 1; // drop that 0 luminance bit!
}
// Median cut!
// https://en.wikipedia.org/wiki/Median_cut
//let buckets = [input.slice()];
// preface the reserved bits
let buckets = reserved.slice().map((c) => [atariRGB[c]]).concat([input.slice()]);
if (input.length != 160) {
throw new Error('xxx bad input size');
}
/*
let buckets = [input.slice()];
if (reserved.length > 0) {
let pxPerReserved = input.length;
for (let c of reserved) {
for (let i = 0; i < pxPerReserved; i++) {
buckets[0].unshift(atariRGB[c]);
}
}
console.log(buckets[0].length, 'xxx');
}
*/
let magicSort = (picker) => (a, b) => {
let bychannel = picker(b) - picker(a);
if (bychannel) return bychannel;
let byluma = b.luma() - a.luma();
return byluma;
};
let medianCut = (bucket, range) => {
if (bucket.length < 2) {
throw new Error('short bucket');
}
//console.log('medianCut', bucket, range);
// Sort by the channel with the greatest range,
// then cut the bucket in two at the median.
if (range.g >= range.r && range.g >= range.b) {
//bucket.sort((a, b) => b.g - a.g);
bucket.sort(magicSort((rgb) => rgb.g));
} else if (range.r >= range.g && range.r >= range.b) {
//bucket.sort((a, b) => b.r - a.r);
bucket.sort(magicSort((rgb) => rgb.r));
} else if (range.b >= range.g && range.b >= range.r) {
//bucket.sort((a, b) => b.b - a.b);
bucket.sort(magicSort((rgb) => rgb.b));
}
let half = bucket.length >> 1;
//console.log('cutting', half, bucket.length);
let [bottom, top] = [bucket.slice(0, half), bucket.slice(half)];
//console.log({bottom, top});
return [bottom, top];
//return [bucket.slice(0, half), bucket.slice(half)];
};
while (buckets.length < n) {
// Find the bucket with the greatest range in any channel
let ranges = buckets.map((bucket) => {
if (bucket.length == 0) {
throw new Error('xxx empty bucket');
}
let red = bucket.map((rgb) => rgb.r);
let green = bucket.map((rgb) => rgb.g);
let blue = bucket.map((rgb) => rgb.b);
return new RGB(
Math.max(...red) - Math.min(...red),
Math.max(...green) - Math.min(...green),
Math.max(...blue) - Math.min(...blue)
);
});
let topRanges = ranges.map((rgb) => Math.max(rgb.r, rgb.g, rgb.b));
//let greatest = Math.max(...topRanges);
//let index = topRanges.indexOf(greatest);
let greatest = 0;
let index = -1;
for (let i = 0; i < topRanges.length; i++) {
//if (topRanges[i] >= greatest) {
if (topRanges[i] > greatest) {
greatest = topRanges[i];
index = i;
}
}
if (index == -1) {
// We just ran out of colors! Pad the buckets.
//while (buckets.length < n) {
// buckets.push([new RGB(0, 0, 0)]);
//}
break;
}
let [lo, hi] = medianCut(buckets[index], ranges[index]);
buckets.splice(index, 1, lo, hi);
}
if (buckets.length > n) {
throw new Error('xxx too many colors assigned');
}
decimated = buckets.map((bucket) => {
// Average the RGB colors in this chunk
let rgb = bucket
.reduce((acc, rgb) => acc.inc(rgb), new RGB(0, 0, 0))
.divide(bucket.length);
// Scale the average to the brightest
let avg_luma = rgb.luma();
let lumas = bucket.map((rgb) => rgb.luma());
let brightest = Math.max(...lumas);
if (avg_luma > 0) {
rgb = rgb.multiply(brightest / avg_luma).clamp();
}
// this also works pretty ok
// but i think keeping luma is better
//
/*
// 1) take the brightest luma
// 2) take the most saturated chroma
// 3) profit!
let lumas = bucket.map((rgb) => rgb.luma());
let brightest = Math.max(...lumas);
let saturations = bucket.map((rgb) => Math.max(rgb.r, rgb.g, rgb.b) - Math.min(rgb.r, rgb.g, rgb.b));
let saturation = Math.max(...saturations);
let saturatedIndex = saturations.indexOf(saturation);
let rgb = bucket[saturatedIndex];
let luma = rgb.luma();
if (luma > 0) {
rgb = rgb.multiply(brightest / luma).clamp();
}
*/
// pick the luma-brightest color in the bucket
// kinda nice but really aggressive with the colors
/*
let lumas = bucket.map((rgb) => rgb.luma());
let luma = Math.max(...lumas);
let rgb = bucket[lumas.indexOf(luma)];
*/
// Take the channel-brightest color in the bucket
// bad
//let rgb = bucket[bucket.length - 1];
// Take the luma-brightest color in the bucket
// wrong? bad
//let rgb = bucket.slice().sort((a, b) => b.luma() - a.luma())[bucket.length - 1];
// Take the median color in the bucket
// bad
//let rgb = bucket[bucket.length >> 1];
// Combine the brightest of each channel
// this is kinda good
/*
let rgb = new RGB(
Math.max(...bucket.map((rgb) => rgb.r)),
Math.max(...bucket.map((rgb) => rgb.g)),
Math.max(...bucket.map((rgb) => rgb.b))
);
*/
// combine the median of each channel
// sux
/*
let rgb = new RGB(
bucket.map((rgb) => rgb.r).sort((a, b) => b - a)[bucket.length >> 1],
bucket.map((rgb) => rgb.g).sort((a, b) => b - a)[bucket.length >> 1],
bucket.map((rgb) => rgb.b).sort((a, b) => b - a)[bucket.length >> 1]
);
*/
// Take the luma-median color in the bucket
//let rgb = bucket.slice().sort((a, b) => b.luma() - a.luma())[bucket.length >> 1];
// Take the brightest-channel median
//let rgb = bucket.slice()
// .sort((a, b) => Math.max(b.r, b.g, b.b) - Math.max(a.r, b.g, b.b))[bucket.length >> 1];
// And map into the Atari palette
let dists = palette.map((i) => rgb.difference(atariRGB[i]).magnitude());
let closest = Math.min(...dists);
let index = dists.indexOf(closest);
return palette[index];
});
decimated.sort((a, b) => a - b);
// Palette fits
return dither(decimated);
} }
/** /**
@ -781,10 +306,10 @@ async function loadImage(src) {
let height = image.bitmap.height; let height = image.bitmap.height;
let aspect = width / height; let aspect = width / height;
let dar = 2 / 1.2; let dar = 4 / 1.2;
if (aspect > ((320 / 1.2) / 192)) { if (aspect > ((320 / 1.2) / 192)) {
// wide // wide
width = 160; width = 80;
height = Math.round((width * image.bitmap.height / image.bitmap.width) * dar); height = Math.round((width * image.bitmap.height / image.bitmap.width) * dar);
if (height & 1) { if (height & 1) {
height++; height++;
@ -820,8 +345,8 @@ function imageToLinearRGB(rgba) {
} }
/** /**
* Read an image file, squish to 160px if necessary, * Read an image file, squish to 80px if necessary,
* and dither to 4 colors per scan line. * and dither to 16 monochrome levels per scan line.
* *
* @param {string} source path to source image file * @param {string} source path to source image file
* @returns {{width: number, height: number, lines: {palette: Array, output: Uint8Array}[]}} * @returns {{width: number, height: number, lines: {palette: Array, output: Uint8Array}[]}}
@ -834,8 +359,8 @@ async function convert(source) {
rgba rgba
} = await loadImage(source); } = await loadImage(source);
if (width > 160) { if (width > 80) {
throw new Error(`expected <160px width, got ${width} pixels`); throw new Error(`expected <80px width, got ${width} pixels`);
} }
if (height > 192) { if (height > 192) {
@ -860,17 +385,10 @@ async function convert(source) {
throw new Error('inconsistent data size on input'); throw new Error('inconsistent data size on input');
} }
// Start with all colors usable with regular CTIA modes
// (not the 16-luminance special mode on GTIA)
let allColors = [];
for (let i = 0; i < 256; i += 2) {
allColors.push(i);
}
let left = [], right = []; let left = [], right = [];
let padding = 0; let padding = 0;
if (width < 160) { if (width < 80) {
padding = 160 - width; padding = 80 - width;
let black = new RGB(0, 0, 0); let black = new RGB(0, 0, 0);
left = repeat(black, padding >> 1); left = repeat(black, padding >> 1);
@ -889,7 +407,7 @@ async function convert(source) {
let error = lines[y - 1].error; let error = lines[y - 1].error;
inputLine = inputLine.map((rgb, x) => rgb.add(error[x]).clamp()); inputLine = inputLine.map((rgb, x) => rgb.add(error[x]).clamp());
} }
let line = decimate(inputLine, allColors, 4, y); let line = colorize(inputLine);
lines.push(line); lines.push(line);
} }
return { return {
@ -934,15 +452,11 @@ function genAssembly(width, height, nbits, lines) {
let half = stride * height / 2; let half = stride * height / 2;
let frame = { let frame = {
palette1: new Uint8Array(height), palette1: new Uint8Array(height),
palette2: new Uint8Array(height),
palette3: new Uint8Array(height),
bitmap: new Uint8Array(stride * height), bitmap: new Uint8Array(stride * height),
}; };
for (let y = 0; y < height; y++) { for (let y = 0; y < height; y++) {
let base = 0; let base = 0;
frame.palette1[y] = lines[y + base].palette[1]; frame.palette1[y] = lines[y + base].palette[1];
frame.palette2[y] = lines[y + base].palette[2];
frame.palette3[y] = lines[y + base].palette[3];
indexedToBitmap( indexedToBitmap(
width, width,
nbits, nbits,
@ -957,10 +471,6 @@ function genAssembly(width, height, nbits, lines) {
.export frame1_palette1_even .export frame1_palette1_even
.export frame1_palette1_odd .export frame1_palette1_odd
.export frame1_palette2_even
.export frame1_palette2_odd
.export frame1_palette3_even
.export frame1_palette3_odd
.export displaylist .export displaylist
@ -982,22 +492,6 @@ ${byte2byte(even(frame.palette1))}
frame1_palette1_odd: frame1_palette1_odd:
${byte2byte(odd(frame.palette1))} ${byte2byte(odd(frame.palette1))}
.align 128
frame1_palette2_even:
${byte2byte(even(frame.palette2))}
.align 128
frame1_palette2_odd:
${byte2byte(odd(frame.palette2))}
.align 128
frame1_palette3_even:
${byte2byte(even(frame.palette3))}
.align 128
frame1_palette3_odd:
${byte2byte(odd(frame.palette3))}
@ -1011,16 +505,17 @@ displaylist:
.byte $f0 ; 8 blank lines .byte $f0 ; 8 blank lines
; ${height} lines graphics ; ${height} lines graphics
; ANTIC mode e (160px 2bpp, 1 scan line per line) ; ANTIC mode f (320px 1bpp, 1 scan line per line)
.byte $4e ; this is modified into GTIA grayscale mode
.byte $4f
.addr frame1_top .addr frame1_top
.repeat ${height / 2 - 1} .repeat ${height / 2 - 1}
.byte $0e .byte $0f
.endrep .endrep
.byte $4e .byte $4f
.addr frame1_bottom .addr frame1_bottom
.repeat ${height / 2 - 1} .repeat ${height / 2 - 1}
.byte $0e .byte $0f
.endrep .endrep
.byte $41 ; jump and blank .byte $41 ; jump and blank
@ -1077,7 +572,7 @@ async function main() {
process.exit(1); process.exit(1);
} }
let nbits = 2; let nbits = 4;
let {width, height, lines} = await convert(process.argv[2], nbits); let {width, height, lines} = await convert(process.argv[2], nbits);

View file

@ -7,6 +7,7 @@ COLPF1 = $D017
COLPF2 = $D018 COLPF2 = $D018
COLPF3 = $D019 COLPF3 = $D019
COLBK = $D01A COLBK = $D01A
PRIOR = $D01B
AUDC1 = $D201 AUDC1 = $D201
DMACTL = $D400 DMACTL = $D400
@ -43,10 +44,6 @@ scanline_max = (lines_per_frame - scanline_offset) / 2
.import frame1_bottom .import frame1_bottom
.import frame1_palette1_even .import frame1_palette1_even
.import frame1_palette1_odd .import frame1_palette1_odd
.import frame1_palette2_even
.import frame1_palette2_odd
.import frame1_palette3_even
.import frame1_palette3_odd
.import displaylist .import displaylist
.code .code
@ -58,6 +55,12 @@ scanline_max = (lines_per_frame - scanline_offset) / 2
lda #$00 lda #$00
sta DMACTL sta DMACTL
; Enable GTIA monochrome mode
lda PRIOR
and #$3f
ora #$40
sta PRIOR
; Disable VBI and DLI but allow Reset ; Disable VBI and DLI but allow Reset
lda #$20 lda #$20
sta NMIEN sta NMIEN
@ -101,22 +104,15 @@ wait_loop:
;ldy scanline ; 3 cyc ;ldy scanline ; 3 cyc
;inc scanline ; 5 cyc ;inc scanline ; 5 cyc
; 23-26 cycles before break ; 8-9 cycles before break
; Leisurely memory fetches ; Leisurely memory fetches
lda frame1_palette1_even + frame_offset + line_offset - scanline_offset / 2,y ; 4/5 @FIXME alternate lda frame1_palette1_even + frame_offset + line_offset - scanline_offset / 2,y ; 4/5 @FIXME alternate
pha ; 3
ldx frame1_palette2_even + frame_offset + line_offset - scanline_offset / 2,y ; 4/5
lda frame1_palette3_even + frame_offset + line_offset - scanline_offset / 2,y ; 4/5
tay ; 2
pla ; 3
; Wait for horizontal blank ; Wait for horizontal blank
sta WSYNC ; 4 sta WSYNC ; 4
; 12 cycles after break ; 4 cycles after break
; Update color registers as fast as possible ; Update color registers as fast as possible
sta COLPF0 ; 4 sta COLPF0 ; 4
stx COLPF1 ; 4
sty COLPF2 ; 4
.endmacro .endmacro
.macro run_frame frame_offset .macro run_frame frame_offset