diff --git a/dither-image.js b/dither-image.js index 93fddd9..bc963dd 100644 --- a/dither-image.js +++ b/dither-image.js @@ -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 = []; for (let i = 0; i < 256; 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. - * If the number of used colors exceeds `n`, the - * palette will be reduced until it fits. + * Dither RGB input data and pick a monochrome palette. * @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[]}} */ -function decimate(input, palette, n) { - +function colorize(input) { + let width = input.length; let inputPixel = (x, error) => { @@ -508,8 +239,7 @@ function decimate(input, palette, n) { } let output = zeroes(width); - let popularity = zeroes(palette.length); - let distance2 = 0; + let distance = 0; let nextError = new RGB(0, 0, 0); @@ -533,7 +263,7 @@ function decimate(input, palette, n) { } output[x] = pick; - popularity[pick]++; + distance += shortest; let share = (n) => nextError.multiply(n / 16); @@ -545,228 +275,23 @@ function decimate(input, palette, n) { return { output, palette, - distance2, - popularity, + distance, error: error.next }; }; - - let decimated = palette.slice(); - - // force to grayscale - //decimated = [0, 5, 10, 15]; - - // force to rgb - //decimated = [0, 0x36, 0xb6, 0x86]; - - // 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 + let best = null; + for (let hue = 0; hue < 16; hue++) { + let palette = []; + for (let i = 0; i < 16; i++) { + palette[i] = (hue << 4) | i; + } + let variant = dither(palette); + if (!best || variant.distance < best.distance) { + best = variant; + } } - */ - - 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); + return best; } /** @@ -781,10 +306,10 @@ async function loadImage(src) { let height = image.bitmap.height; let aspect = width / height; - let dar = 2 / 1.2; + let dar = 4 / 1.2; if (aspect > ((320 / 1.2) / 192)) { // wide - width = 160; + width = 80; height = Math.round((width * image.bitmap.height / image.bitmap.width) * dar); if (height & 1) { height++; @@ -820,8 +345,8 @@ function imageToLinearRGB(rgba) { } /** - * Read an image file, squish to 160px if necessary, - * and dither to 4 colors per scan line. + * Read an image file, squish to 80px if necessary, + * and dither to 16 monochrome levels per scan line. * * @param {string} source path to source image file * @returns {{width: number, height: number, lines: {palette: Array, output: Uint8Array}[]}} @@ -834,8 +359,8 @@ async function convert(source) { rgba } = await loadImage(source); - if (width > 160) { - throw new Error(`expected <160px width, got ${width} pixels`); + if (width > 80) { + throw new Error(`expected <80px width, got ${width} pixels`); } if (height > 192) { @@ -860,17 +385,10 @@ async function convert(source) { 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 padding = 0; - if (width < 160) { - padding = 160 - width; + if (width < 80) { + padding = 80 - width; let black = new RGB(0, 0, 0); left = repeat(black, padding >> 1); @@ -889,7 +407,7 @@ async function convert(source) { let error = lines[y - 1].error; inputLine = inputLine.map((rgb, x) => rgb.add(error[x]).clamp()); } - let line = decimate(inputLine, allColors, 4, y); + let line = colorize(inputLine); lines.push(line); } return { @@ -934,15 +452,11 @@ function genAssembly(width, height, nbits, lines) { let half = stride * height / 2; let frame = { palette1: new Uint8Array(height), - palette2: new Uint8Array(height), - palette3: new Uint8Array(height), bitmap: new Uint8Array(stride * height), }; for (let y = 0; y < height; y++) { let base = 0; 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( width, nbits, @@ -957,10 +471,6 @@ function genAssembly(width, height, nbits, lines) { .export frame1_palette1_even .export frame1_palette1_odd -.export frame1_palette2_even -.export frame1_palette2_odd -.export frame1_palette3_even -.export frame1_palette3_odd .export displaylist @@ -982,22 +492,6 @@ ${byte2byte(even(frame.palette1))} frame1_palette1_odd: ${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 ; ${height} lines graphics - ; ANTIC mode e (160px 2bpp, 1 scan line per line) - .byte $4e + ; ANTIC mode f (320px 1bpp, 1 scan line per line) + ; this is modified into GTIA grayscale mode + .byte $4f .addr frame1_top .repeat ${height / 2 - 1} - .byte $0e + .byte $0f .endrep - .byte $4e + .byte $4f .addr frame1_bottom .repeat ${height / 2 - 1} - .byte $0e + .byte $0f .endrep .byte $41 ; jump and blank @@ -1077,7 +572,7 @@ async function main() { process.exit(1); } - let nbits = 2; + let nbits = 4; let {width, height, lines} = await convert(process.argv[2], nbits); diff --git a/mono16.s b/mono16.s index fd4c0ec..b1ba24c 100644 --- a/mono16.s +++ b/mono16.s @@ -7,6 +7,7 @@ COLPF1 = $D017 COLPF2 = $D018 COLPF3 = $D019 COLBK = $D01A +PRIOR = $D01B AUDC1 = $D201 DMACTL = $D400 @@ -43,10 +44,6 @@ scanline_max = (lines_per_frame - scanline_offset) / 2 .import frame1_bottom .import frame1_palette1_even .import frame1_palette1_odd -.import frame1_palette2_even -.import frame1_palette2_odd -.import frame1_palette3_even -.import frame1_palette3_odd .import displaylist .code @@ -58,6 +55,12 @@ scanline_max = (lines_per_frame - scanline_offset) / 2 lda #$00 sta DMACTL + ; Enable GTIA monochrome mode + lda PRIOR + and #$3f + ora #$40 + sta PRIOR + ; Disable VBI and DLI but allow Reset lda #$20 sta NMIEN @@ -101,22 +104,15 @@ wait_loop: ;ldy scanline ; 3 cyc ;inc scanline ; 5 cyc - ; 23-26 cycles before break + ; 8-9 cycles before break ; Leisurely memory fetches 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 sta WSYNC ; 4 - ; 12 cycles after break + ; 4 cycles after break ; Update color registers as fast as possible sta COLPF0 ; 4 - stx COLPF1 ; 4 - sty COLPF2 ; 4 .endmacro .macro run_frame frame_offset