いつか NESDOOM 的な技術 (PPU がアクセスするメモリの内容として、通常ではあり得ない内容を返す事でファミコンの本来の表現能力を超えたグラフィックを出力する) でゲームを作りたいなと思っていて、その中で「更に限界を打ち破る手段」として、二色を交互に表示する事で、出せないはずの色を疑似的に出すのもありだなと思っているのですが、その中でとあるツイートを見つけました。
ファミコンではなくてスーパーファミコンになるのですが、スーパードンキーコングではそのやり方が実際に行われているらしいという話がある中で、「60 FPS ではフリッカーが問題になるから出来ない、はい嘘松」という御意見のツイートでした。
その方がスクショで根拠として挙げられていたサイトの内容も確認しましたが、現在は書き換えられているようで、ある特定部分のグラデーション表現でのみ行われているという内容になっています。
スーパードンキーコングで実際にどうなのかというのは、自分にはちょっとわからないのですが、自分にはそれが可能そうだと思える理由がありました。
私が最初に手に入れたパソコンである PC-9801 FA という 16色しか同時に発色できない機種で、「力業ローダー」と呼ばれるプログラムを使って、Tech Win という雑誌の CD-ROM に収録されていたムフフなお姉さんの写真 (実写) をそれなりの表現力でみれたという記憶が、私にはあったのです!! (豪語する内容じゃない!!)
ちなみに PC-98 のリフレッシュレートは 56.4Hz みたいなので最高速でも 60 FPS に届きません。
で、ちょっと前に VBlank を待つような手法ではなく、理論的に 60 FPS にならないかなというスクリプトを書いてたので、ちょっと変えて、実際にどうなるのか確認してみた!!
あくまで実験でやったのをお手元でも動作させて貰える事を目的とした、すごく雑なコードなのでコードはあまりあてにしないでください。
<html>
<head>
<script>
(function(global){
const EmbeddedColor = class {
constructor(idx, r, g, b) {
this.idx = idx;
this.r = r;
this.g = g;
this.b = b;
}
};
const PseudoColor = class {
constructor(fstElement, sndElement) {
this.fstElement = fstElement;
this.sndElement = sndElement;
const idealR = parseInt((fstElement.r + sndElement.r) / 2);
const idealG = parseInt((fstElement.g + sndElement.g) / 2);
const idealB = parseInt((fstElement.b + sndElement.b) / 2);
this.idealR = idealR;
this.idealG = idealG;
this.idealB = idealB;
}
get idealColorString() {
const r = `0${this.idealR.toString(16).toUpperCase()}`.slice(-2);
const g = `0${this.idealG.toString(16).toUpperCase()}`.slice(-2);
const b = `0${this.idealB.toString(16).toUpperCase()}`.slice(-2);
return `#${r}${g}${b}`;
}
};
// 一旦それっぽいパレット
const colorList = [
new EmbeddedColor('$00', 0x62, 0x62, 0x62),
new EmbeddedColor('$01', 0x00, 0x1F, 0xB2),
new EmbeddedColor('$02', 0x24, 0x04, 0xC8),
new EmbeddedColor('$03', 0x52, 0x00, 0xB2),
new EmbeddedColor('$04', 0x73, 0x00, 0x76),
new EmbeddedColor('$05', 0x80, 0x00, 0x24),
new EmbeddedColor('$06', 0x73, 0x0B, 0x00),
new EmbeddedColor('$07', 0x52, 0x28, 0x00),
new EmbeddedColor('$08', 0x24, 0x44, 0x00),
new EmbeddedColor('$09', 0x00, 0x57, 0x00),
new EmbeddedColor('$0A', 0x00, 0x5C, 0x00),
new EmbeddedColor('$0B', 0x00, 0x53, 0x24),
new EmbeddedColor('$0C', 0x00, 0x3C, 0x76),
new EmbeddedColor('$10', 0xAB, 0xAB, 0xAB),
new EmbeddedColor('$11', 0x0D, 0x57, 0xFF),
new EmbeddedColor('$12', 0x4B, 0x30, 0xFF),
new EmbeddedColor('$13', 0x8A, 0x13, 0xFF),
new EmbeddedColor('$14', 0xBC, 0x08, 0xD6),
new EmbeddedColor('$15', 0xD2, 0x12, 0x69),
new EmbeddedColor('$16', 0xC7, 0x2E, 0x00),
new EmbeddedColor('$17', 0x9D, 0x54, 0x00),
new EmbeddedColor('$18', 0x60, 0x7B, 0x00),
new EmbeddedColor('$19', 0x20, 0x98, 0x00),
new EmbeddedColor('$1A', 0x00, 0xA3, 0x00),
new EmbeddedColor('$1B', 0x00, 0x99, 0x42),
new EmbeddedColor('$1C', 0x00, 0x7D, 0xB4),
new EmbeddedColor('$1D', 0x00, 0x00, 0x00),
new EmbeddedColor('$20', 0xFF, 0xFF, 0xFF),
new EmbeddedColor('$21', 0x53, 0xAE, 0xFF),
new EmbeddedColor('$22', 0x90, 0x85, 0xFF),
new EmbeddedColor('$23', 0xD3, 0x65, 0xFF),
new EmbeddedColor('$24', 0xFF, 0x57, 0xFF),
new EmbeddedColor('$25', 0xFF, 0x5D, 0xCF),
new EmbeddedColor('$26', 0xFF, 0x77, 0x57),
new EmbeddedColor('$27', 0xFA, 0x9E, 0x00),
new EmbeddedColor('$28', 0xBD, 0xC7, 0x00),
new EmbeddedColor('$29', 0x7A, 0xE7, 0x00),
new EmbeddedColor('$2A', 0x43, 0xF6, 0x11),
new EmbeddedColor('$2B', 0x26, 0xEF, 0x7E),
new EmbeddedColor('$2C', 0x2C, 0xD5, 0xF6),
new EmbeddedColor('$2D', 0x4E, 0x4E, 0x4E),
// new EmbeddedColor('$30', 0xFF, 0xFF, 0xFF), // $30 is same as $20
new EmbeddedColor('$31', 0xB6, 0xE1, 0xFF),
new EmbeddedColor('$32', 0xCE, 0xD1, 0xFF),
new EmbeddedColor('$33', 0xE9, 0xC3, 0xFF),
new EmbeddedColor('$34', 0xFF, 0xBC, 0xFF),
new EmbeddedColor('$35', 0xFF, 0xBD, 0xF4),
new EmbeddedColor('$36', 0xFF, 0xC6, 0xC3),
new EmbeddedColor('$37', 0xFF, 0xD5, 0x9A),
new EmbeddedColor('$38', 0xE9, 0xE6, 0x81),
new EmbeddedColor('$39', 0xCE, 0xF4, 0x81),
new EmbeddedColor('$3A', 0xB6, 0xFB, 0x9A),
new EmbeddedColor('$3B', 0xA9, 0xFA, 0xC3),
new EmbeddedColor('$3C', 0xA9, 0xF0, 0xF4),
new EmbeddedColor('$3D', 0xB8, 0xB8, 0xB8),
];
document.addEventListener('DOMContentLoaded', function() {
onTick();
});
const draw = function(pattern) {
const colorCount = colorList.length;
// for (var color of colorList) {
// console.log(color);
// console.log(`${color.r} ${color.g} ${color.b}` );
// }
const canvas = document.querySelector('#canvas');
const context = canvas.getContext('2d');
context.fillStyle = 'rgb(255 255 255)';
context.fillRect(0, 0, 300, 300);
const blockWidth = 16;
const blockHalfWidth = blockWidth / 2;
const blockHeight = 16;
const offsetX = 16;
const offsetY = 16;
const fillingEnabled = true;
for (var i = 0; i < colorCount; ++i) {
const color = colorList[i];
const posF = offsetX + (i * blockWidth);
const posS = offsetY + (i * blockHeight);
context.fillStyle = `rgb(${color.r} ${color.g} ${color.b})`;
if (fillingEnabled) {
context.fillRect(posF, 0, blockWidth - 1, blockHeight - 1);
context.fillRect(0, posS, blockWidth - 1, blockHeight - 1);
}
}
var pseudoColorMap = {};
for (var y = 0; y < colorCount; ++y) {
for (var x = 0; x < colorCount; ++x) {
if (x > y) {
continue;
}
const color1 = colorList[x];
const color2 = colorList[y];
const colorTable = [ color1, color2 ];
const pseudoColor = new PseudoColor(color1, color2);
const posX = offsetX + (x * blockWidth);
const posY = offsetY + (y * blockHeight);
if (fillingEnabled) {
const pseudoColorR = pseudoColor.idealR;
const pseudoColorG = pseudoColor.idealG;
const pseudoColorB = pseudoColor.idealB;
const patternColorR = colorTable[pattern % 2].r;
const patternColorG = colorTable[pattern % 2].g;
const patternColorB = colorTable[pattern % 2].b;
if (false) {
// 想定した色と実際に力技した時の半々
context.fillStyle = `rgb(${patternColorR} ${patternColorG} ${patternColorB})`;
context.fillRect(posX + blockHalfWidth, posY, blockHalfWidth - 1, blockHeight - 1);
context.fillStyle = `rgb(${pseudoColorR} ${pseudoColorG} ${pseudoColorB})`;
context.fillRect(posX, posY, blockHalfWidth - 1, blockHeight - 1);
} else if (true) {
// 実際に力技した時の色
context.fillStyle = `rgb(${patternColorR} ${patternColorG} ${patternColorB})`;
context.fillRect(posX, posY, blockWidth - 1, blockHeight - 1);
} else {
// 想定した色
context.fillStyle = `rgb(${pseudoColorR} ${pseudoColorG} ${pseudoColorB})`;
context.fillRect(posX, posY, blockWidth - 1, blockHeight - 1);
}
}
// console.log(`${r} ${g} ${b}`);
if (!(pseudoColor.idealColorString in pseudoColorMap)) {
pseudoColorMap[pseudoColor.idealColorString] = [];
}
pseudoColorMap[pseudoColor.idealColorString].push(pseudoColor);
// console.log(pseudoColor.idealColorString);
}
}
// for (var key of Object.keys(pseudoColorMap)) {
// const entry = pseudoColorMap[key];
// if (entry.length >= 2) {
// console.log(`${key} ${entry.length}`);
// console.log(entry);
// }
// }
};
// 二つの数の最大公約数 (greatest common divisor) を求めます.
const gcd2 = function(value1, value2) {
if ((value1 == 0) || (value2 == 0)) {
// 想定外の引数
throw "illegal argument";
}
// ユーグリッドの互除法 (Euclidean Algorithm)
while (value2 != 0) {
const tmpValue = value2;
value2 = value1 % value2;
value1 = tmpValue;
};
return value1;
};
// `createCycleTable` 理想 FPS の為に各フレームで待つべき時間の表を作成します.
// `waitsPerSecond` 一秒辺りの待ち時間 (ミリ秒単位なら 1000, マイクロ秒単位なら 1000000)
// `fpsToDesire` 理想秒間フレーム数
const createCycleTable = function(waitsPerSecond, fpsToDesire) {
const baseTime = parseInt(waitsPerSecond / fpsToDesire);
const remaindered = waitsPerSecond % fpsToDesire;
if (remaindered == 0) {
return [baseTime];
}
const gcdValue = gcd2(fpsToDesire, remaindered);
const cycleItemCount = parseInt(fpsToDesire / gcdValue);
const longItemCount = parseInt(remaindered / gcdValue);
var cycleTable = [];
const mag = 65536;
const toDecrease = parseInt(mag * longItemCount / cycleItemCount);
var counter = mag * longItemCount;
for (var i = 0; i < cycleItemCount; i += 1)
{
const prev = parseInt(counter / mag);
counter -= toDecrease;
const curr = parseInt(counter / mag);
if (prev != curr) {
cycleTable[i] = baseTime + 1;
} else {
cycleTable[i] = baseTime;
}
}
// 確かめ算 verification of figures
var cntForVerification = 0;
var pos = 0;
for (var i = 0; i < fpsToDesire; i += 1) {
cntForVerification += cycleTable[pos];
pos += 1;
pos %= cycleItemCount;
}
if (cntForVerification != waitsPerSecond) {
throw `verification failed. expects: ${waitsPerSecond}, but actual: ${cntForVerification}`;
}
return cycleTable;
};
const getTickCount = function() {
return (new Date()).getTime();
};
var progress = 0;
var increment = 15;
var max = 360;
var pattern = 0;
// [17, 17, 16] で 約 60 FPS
var tickCycleTable = [17, 17, 16];
// var tickCycleTable = [1000];
var tickCycleTableIndex = 0;
var tickCount = getTickCount();
const onTick = function() {
const thisTickStartedAt = getTickCount();
const interval = tickCycleTable[tickCycleTableIndex];
tickCycleTableIndex += 1;
tickCycleTableIndex %= tickCycleTable.length;
// console.log(tickCycleTableIndex);
const nextTickShouldStartAt = tickCount + interval;
tickCount += interval;
// ----
pattern += 1;
pattern %= 2;
draw(pattern);
// console.log(`${progress} ${mage}`);
progress += increment;
progress %= max;
// ----
const tickProcessEndedAt = getTickCount();
var delay = nextTickShouldStartAt - tickProcessEndedAt;
if (delay < 0) {
console.log('間に合わない');
tickCount = tickProcessEndedAt;
delay = interval;
}
setTimeout(onTick, delay);
};
})(this);
</script>
</head>
<body>
<canvas id="canvas" width="864" height="864" />
</body>
</html>
注意として、結構ピカピカチューするので、てんかんの発作のある方などは表示をみないようにして頂きたいです。
これを HTML ファイルとして保存して、ブラウザで表示すると、約 60 FPS で二色を交互に表示します。
右上半分がないのは、左下半分の逆パターンにしかならない為です。
斜めのラインの所は二色とも同じ色になる所です。
どうでしょうか?
自分の感覚でみると、白から黒にかけての色が混じると確かに点滅感が激しいように思えます。
また、二色の明るさの差が大きい場合も結構厳しい感じがします。
その反面、明るさが近い色同士では充分成立してるように思いませんか?
つまり、組み合わせたらダメな色もありそう (ポリゴンショック) だけれども、大丈夫な色もありそうだと思いましたこのみ。
どう考えるかは up to you!!
(あ、出来らぁ!って虚勢はる流れ忘れた)