mirror of
https://github.com/zellyn/apple2shader.git
synced 2024-12-26 23:29:24 +00:00
1520 lines
47 KiB
JavaScript
1520 lines
47 KiB
JavaScript
"use strict";
|
|
|
|
const screenEmu = (function () {
|
|
|
|
// Classes
|
|
|
|
const Point = class {
|
|
constructor(x, y) {
|
|
this.x = x;
|
|
this.y = y;
|
|
}
|
|
}
|
|
|
|
const Size = class {
|
|
constructor(width, height) {
|
|
this.width = width;
|
|
this.height = height;
|
|
}
|
|
|
|
copy() {
|
|
return new Size(this.width, this.height);
|
|
}
|
|
|
|
get ratio() {
|
|
return this.width / this.height;
|
|
}
|
|
}
|
|
|
|
const Rect = class {
|
|
constructor(x, y, width, height) {
|
|
this.origin = new Point(x, y);
|
|
this.size = new Size(width, height);
|
|
}
|
|
|
|
get x() {
|
|
return this.origin.x;
|
|
}
|
|
|
|
get y() {
|
|
return this.origin.y;
|
|
}
|
|
|
|
get width() {
|
|
return this.size.width;
|
|
}
|
|
|
|
get height() {
|
|
return this.size.height;
|
|
}
|
|
|
|
get l() {
|
|
return this.origin.x;
|
|
}
|
|
|
|
get r() {
|
|
return this.origin.x + this.size.width;
|
|
}
|
|
|
|
get t() {
|
|
return this.origin.y;
|
|
}
|
|
|
|
get b() {
|
|
return this.origin.y + this.size.height;
|
|
}
|
|
}
|
|
|
|
const Vector = class {
|
|
constructor(n) {
|
|
this.data = new Float32Array(n);
|
|
}
|
|
|
|
// Normalize the vector.
|
|
normalize() {
|
|
const vec = this.data;
|
|
let sum = 0;
|
|
for (const item of vec) {
|
|
sum += item;
|
|
}
|
|
const gain = 1/sum;
|
|
for (const i in vec) {
|
|
vec[i] *= gain;
|
|
}
|
|
return this;
|
|
}
|
|
|
|
// Multiply this Vector by another, or by a number.
|
|
mul(other) {
|
|
const w = new Vector(0);
|
|
if ((typeof other != "number") && (this.data.length != other.data.length)) {
|
|
return w;
|
|
}
|
|
|
|
w.data = new Float32Array(this.data);
|
|
|
|
for (let i = 0; i < w.data.length; i++) {
|
|
if (typeof other == "number") {
|
|
w.data[i] *= other;
|
|
} else {
|
|
w.data[i] *= other.data[i];
|
|
}
|
|
}
|
|
|
|
return w;
|
|
}
|
|
|
|
realIDFT() {
|
|
const size = this.data.length;
|
|
const w = new Vector(size);
|
|
for (let i = 0; i < size; i++) {
|
|
const omega = 2 * Math.PI * i / size;
|
|
|
|
for (let j = 0; j < size; j++) {
|
|
w.data[i] += this.data[j] * Math.cos(j * omega);
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < size; i++) {
|
|
w.data[i] /= size;
|
|
}
|
|
|
|
return w;
|
|
}
|
|
|
|
resize(n) {
|
|
const newData = new Float32Array(n);
|
|
for (let i=0; i < Math.min(newData.length, this.data.length); i++) {
|
|
newData[i] = this.data[i];
|
|
}
|
|
this.data = newData;
|
|
return this;
|
|
}
|
|
|
|
// Chebyshev Window
|
|
//
|
|
// Based on ideas at:
|
|
// http://www.dsprelated.com/showarticle/42.php
|
|
//
|
|
static chebyshevWindow(n, sidelobeDb) {
|
|
const m = n - 1;
|
|
let w = new Vector(m);
|
|
|
|
const alpha = Math.cosh(Math.acosh(Math.pow(10, sidelobeDb / 20)) / m);
|
|
for (let i = 0; i < m; i++) {
|
|
const a = Math.abs(alpha * Math.cos(Math.PI * i / m));
|
|
if (a > 1)
|
|
w.data[i] = Math.pow(-1, i) * Math.cosh(m * Math.acosh(a));
|
|
else
|
|
w.data[i] = Math.pow(-1, i) * Math.cos(m * Math.acos(a));
|
|
}
|
|
|
|
w = w.realIDFT();
|
|
|
|
w.resize(n);
|
|
w.data[0] /= 2;
|
|
w.data[n-1] = w.data[0];
|
|
|
|
const max = w.data.reduce((prev, cur) => Math.max(prev, Math.abs(cur)));
|
|
for (const i in w.data) {
|
|
w.data[i] /= max;
|
|
}
|
|
|
|
return w;
|
|
}
|
|
|
|
// Lanczos Window
|
|
static lanczosWindow(n, fc) {
|
|
let v = new Vector(n);
|
|
fc = Math.min(fc, 0.5);
|
|
const halfN = Math.floor(n / 2);
|
|
|
|
for (let i = 0; i < n; i++) {
|
|
const x = 2 * Math.PI * fc * (i - halfN);
|
|
|
|
v.data[i] = (x == 0.0) ? 1.0 : Math.sin(x) / x;
|
|
}
|
|
|
|
return v;
|
|
}
|
|
|
|
};
|
|
|
|
const Matrix3 = class {
|
|
constructor(c00, c01, c02,
|
|
c10, c11, c12,
|
|
c20, c21, c22) {
|
|
this.data = new Float32Array([c00, c01, c02, c10, c11, c12, c20, c21, c22]);
|
|
}
|
|
|
|
at(i, j) {
|
|
return this.data[3 * i + j];
|
|
}
|
|
|
|
mul(val) {
|
|
const m = new Matrix3(0,0,0,0,0,0,0,0,0);
|
|
if (typeof val == "number") {
|
|
m.data = this.data.map(x => x * val);
|
|
} else {
|
|
for (let i = 0; i < 3; i++) {
|
|
for (let j = 0; j < 3; j++) {
|
|
for (let k = 0; k < 3; k++) {
|
|
m.data[i * 3 + j] += val.data[i * 3 + k] * this.data[k * 3 + j];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return m;
|
|
}
|
|
}
|
|
|
|
|
|
// From AppleIIVideo.cpp
|
|
|
|
const HORIZ_START = 16;
|
|
const HORIZ_BLANK = (9 + HORIZ_START) // 25;
|
|
const HORIZ_DISPLAY = 40;
|
|
const HORIZ_TOTAL = (HORIZ_BLANK + HORIZ_DISPLAY) // 65;
|
|
|
|
const CELL_WIDTH = 14;
|
|
const CELL_HEIGHT = 8;
|
|
|
|
const VERT_NTSC_START = 38;
|
|
const VERT_PAL_START = 48;
|
|
const VERT_DISPLAY = 192;
|
|
|
|
const BLOCK_WIDTH = HORIZ_DISPLAY; // 40
|
|
const BLOCK_HEIGHT = (VERT_DISPLAY / CELL_HEIGHT); // 24
|
|
|
|
// From CanvasInterface.h
|
|
|
|
const NTSC_FSC = 315/88 * 1e6; // 3579545 = 3.5 Mhz: Color Subcarrier
|
|
const NTSC_4FSC = 4 * NTSC_FSC; // 14318180 = 14.3 Mhz
|
|
const NTSC_HTOTAL = (63+5/9) * 1e-6;
|
|
const NTSC_HLENGTH = (52+8/9) * 1e-6;
|
|
const NTSC_HHALF = (35+2/3) * 1e-6;
|
|
const NTSC_HSTART = NTSC_HHALF - NTSC_HLENGTH/2;
|
|
const NTSC_HEND = NTSC_HHALF + NTSC_HLENGTH/2;
|
|
const NTSC_VTOTAL = 262;
|
|
const NTSC_VLENGTH = 240;
|
|
const NTSC_VSTART = 19;
|
|
const NTSC_VEND = NTSC_VSTART + NTSC_VLENGTH;
|
|
|
|
const PAL_FSC = 4433618.75; // Color subcarrier
|
|
const PAL_4FSC = 4 * PAL_FSC;
|
|
const PAL_HTOTAL = 64e-6;
|
|
const PAL_HLENGTH = 52e-6;
|
|
const PAL_HHALF = (37+10/27) * 1e-6;
|
|
const PAL_HSTART = PAL_HHALF - PAL_HLENGTH / 2;
|
|
const PAL_HEND = PAL_HHALF + PAL_HLENGTH / 2;
|
|
const PAL_VTOTAL = 312;
|
|
const PAL_VLENGTH = 288;
|
|
const PAL_VSTART = 21;
|
|
const PAL_VEND = PAL_VSTART + PAL_VLENGTH;
|
|
|
|
// From OpenGLCanvas.cpp
|
|
const NTSC_I_CUTOFF = 1300000;
|
|
const NTSC_Q_CUTOFF = 600000;
|
|
const NTSC_IQ_DELTA = NTSC_I_CUTOFF - NTSC_Q_CUTOFF;
|
|
|
|
// From AppleIIVideo::updateTiming
|
|
const ntscClockFrequency = NTSC_4FSC * HORIZ_TOTAL / 912;
|
|
const ntscVisibleRect = new Rect(ntscClockFrequency * NTSC_HSTART, NTSC_VSTART,
|
|
ntscClockFrequency * NTSC_HLENGTH, NTSC_VLENGTH);
|
|
const ntscDisplayRect = new Rect(HORIZ_START, VERT_NTSC_START,
|
|
HORIZ_DISPLAY, VERT_DISPLAY);
|
|
const ntscVertTotal = NTSC_VTOTAL;
|
|
|
|
const palClockFrequency = 14250450.0 * HORIZ_TOTAL / 912;
|
|
const palVisibleRect = new Rect(palClockFrequency * PAL_HSTART, PAL_VSTART,
|
|
palClockFrequency * PAL_HLENGTH, PAL_VLENGTH);
|
|
const palDisplayRect = new Rect(HORIZ_START, VERT_PAL_START,
|
|
HORIZ_DISPLAY, VERT_DISPLAY);
|
|
const palVertTotal = PAL_VTOTAL;
|
|
|
|
const VERTEX_RENDER_SHADER =`
|
|
// an attribute will receive data from a buffer
|
|
attribute vec4 a_position;
|
|
attribute vec2 a_texCoord;
|
|
varying vec2 v_texCoord;
|
|
|
|
// all shaders have a main function
|
|
void main() {
|
|
gl_Position = a_position;
|
|
v_texCoord = a_texCoord;
|
|
}
|
|
`;
|
|
|
|
const VERTEX_DISPLAY_SHADER =`
|
|
// an attribute will receive data from a buffer
|
|
attribute vec4 a_position;
|
|
attribute vec2 a_texCoord;
|
|
attribute vec2 a_texCoord2;
|
|
varying vec2 v_texCoord;
|
|
varying vec2 v_texCoord2;
|
|
|
|
// all shaders have a main function
|
|
void main() {
|
|
gl_Position = vec4(a_position.x, -a_position.y, a_position.z, a_position.w);
|
|
v_texCoord = a_texCoord;
|
|
v_texCoord2 = a_texCoord2;
|
|
}
|
|
`;
|
|
|
|
const COMPOSITE_SHADER = `
|
|
precision mediump float;
|
|
|
|
varying vec2 v_texCoord;
|
|
|
|
uniform sampler2D texture;
|
|
uniform vec2 textureSize;
|
|
uniform float subcarrier;
|
|
uniform sampler2D phaseInfo;
|
|
uniform vec3 c0, c1, c2, c3, c4, c5, c6, c7, c8;
|
|
uniform mat3 decoderMatrix;
|
|
uniform vec3 decoderOffset;
|
|
|
|
float PI = 3.14159265358979323846264;
|
|
|
|
vec3 pixel(in vec2 q)
|
|
{
|
|
vec3 c = texture2D(texture, q).rgb;
|
|
vec2 p = texture2D(phaseInfo, vec2(0, q.y)).rg;
|
|
float phase = 2.0 * PI * (subcarrier * textureSize.x * q.x + p.x);
|
|
return c * vec3(1.0, sin(phase), (1.0 - 2.0 * p.y) * cos(phase));
|
|
}
|
|
|
|
vec3 pixels(vec2 q, float i)
|
|
{
|
|
return pixel(vec2(q.x + i, q.y)) + pixel(vec2(q.x - i, q.y));
|
|
}
|
|
|
|
void main(void)
|
|
{
|
|
vec2 q = v_texCoord;
|
|
vec3 c = pixel(q) * c0;
|
|
c += pixels(q, 1.0 / textureSize.x) * c1;
|
|
c += pixels(q, 2.0 / textureSize.x) * c2;
|
|
c += pixels(q, 3.0 / textureSize.x) * c3;
|
|
c += pixels(q, 4.0 / textureSize.x) * c4;
|
|
c += pixels(q, 5.0 / textureSize.x) * c5;
|
|
c += pixels(q, 6.0 / textureSize.x) * c6;
|
|
c += pixels(q, 7.0 / textureSize.x) * c7;
|
|
c += pixels(q, 8.0 / textureSize.x) * c8;
|
|
gl_FragColor = vec4(decoderMatrix * c + decoderOffset, 1.0);
|
|
}
|
|
`;
|
|
|
|
const DISPLAY_SHADER = `
|
|
precision mediump float;
|
|
|
|
varying vec2 v_texCoord;
|
|
varying vec2 v_texCoord2;
|
|
|
|
uniform sampler2D texture;
|
|
uniform vec2 textureSize;
|
|
uniform float barrel;
|
|
uniform vec2 barrelSize;
|
|
uniform float scanlineLevel;
|
|
uniform sampler2D shadowMask;
|
|
uniform vec2 shadowMaskSize;
|
|
uniform float shadowMaskLevel;
|
|
uniform float centerLighting;
|
|
uniform sampler2D persistence;
|
|
uniform vec2 persistenceSize;
|
|
uniform vec2 persistenceOrigin;
|
|
uniform float persistenceLevel;
|
|
uniform float luminanceGain;
|
|
|
|
float PI = 3.14159265358979323846264;
|
|
|
|
void main(void)
|
|
{
|
|
vec2 qc = (v_texCoord2 - vec2(0.5, 0.5)) * barrelSize;
|
|
vec2 qb = barrel * qc * dot(qc, qc);
|
|
vec2 q = v_texCoord + qb;
|
|
|
|
vec3 c = texture2D(texture, q).rgb;
|
|
|
|
float scanline = sin(PI * textureSize.y * q.y);
|
|
c *= mix(1.0, scanline * scanline, scanlineLevel);
|
|
|
|
vec3 mask = texture2D(shadowMask, (v_texCoord2 + qb) * shadowMaskSize).rgb;
|
|
c *= mix(vec3(1.0, 1.0, 1.0), mask, shadowMaskLevel);
|
|
|
|
vec2 lighting = qc * centerLighting;
|
|
c *= exp(-dot(lighting, lighting));
|
|
|
|
c *= luminanceGain;
|
|
|
|
vec2 qp = v_texCoord2 * persistenceSize + persistenceOrigin;
|
|
c = max(c, texture2D(persistence, qp).rgb * persistenceLevel - 0.5 / 256.0);
|
|
|
|
gl_FragColor = vec4(c, 1.0);
|
|
}
|
|
`;
|
|
|
|
const RGB_SHADER = `
|
|
precision mediump float;
|
|
|
|
varying vec2 v_texCoord;
|
|
|
|
uniform sampler2D texture;
|
|
uniform vec2 textureSize;
|
|
uniform vec3 c0, c1, c2, c3, c4, c5, c6, c7, c8;
|
|
uniform mat3 decoderMatrix;
|
|
uniform vec3 decoderOffset;
|
|
|
|
vec3 pixel(vec2 q)
|
|
{
|
|
return texture2D(texture, q).rgb;
|
|
}
|
|
|
|
vec3 pixels(in vec2 q, in float i)
|
|
{
|
|
return pixel(vec2(q.x + i, q.y)) + pixel(vec2(q.x - i, q.y));
|
|
}
|
|
|
|
void main(void)
|
|
{
|
|
vec2 q = v_texCoord;
|
|
vec3 c = pixel(q) * c0;
|
|
c += pixels(q, 1.0 / textureSize.x) * c1;
|
|
c += pixels(q, 2.0 / textureSize.x) * c2;
|
|
c += pixels(q, 3.0 / textureSize.x) * c3;
|
|
c += pixels(q, 4.0 / textureSize.x) * c4;
|
|
c += pixels(q, 5.0 / textureSize.x) * c5;
|
|
c += pixels(q, 6.0 / textureSize.x) * c6;
|
|
c += pixels(q, 7.0 / textureSize.x) * c7;
|
|
c += pixels(q, 8.0 / textureSize.x) * c8;
|
|
gl_FragColor = vec4(decoderMatrix * c + decoderOffset, 1.0);
|
|
}
|
|
`;
|
|
|
|
function buildTiming(clockFrequency, displayRect, visibleRect, vertTotal, fsc) {
|
|
const vertStart = displayRect.y;
|
|
// Total number of CPU cycles per frame: 17030 for NTSC.
|
|
const frameCycleNum = HORIZ_TOTAL * vertTotal;
|
|
// first displayed column.
|
|
const horizStart = Math.floor(displayRect.x);
|
|
// imageSize is [14 * visible rect width in cells, visible lines]
|
|
const imageSize = new Size(Math.floor(CELL_WIDTH * visibleRect.width),
|
|
Math.floor(visibleRect.height));
|
|
// imageLeft is # of pixels from first visible point to first displayed point.
|
|
const imageLeft = Math.floor((horizStart-visibleRect.x) * CELL_WIDTH);
|
|
const colorBurst = [2 * Math.PI * (-33/360 + (imageLeft % 4) / 4)];
|
|
const cycleNum = frameCycleNum + 16;
|
|
|
|
// First pixel that OpenEmulator draws when painting normally.
|
|
const topLeft = new Point(imageLeft, vertStart - visibleRect.y);
|
|
// First pixel that OpenEmulator draws when painting 80-column mode.
|
|
const topLeft80Col = new Point(imageLeft - CELL_WIDTH/2, vertStart - visibleRect.y);
|
|
|
|
return {
|
|
fsc: fsc,
|
|
clockFrequency: clockFrequency,
|
|
displayRect: displayRect,
|
|
visibleRect: visibleRect,
|
|
vertStart: vertStart,
|
|
vertTotal: vertTotal,
|
|
frameCycleNum: frameCycleNum,
|
|
horizStart: horizStart,
|
|
imageSize: imageSize,
|
|
imageLeft: imageLeft,
|
|
colorBurst: colorBurst,
|
|
cycleNum: cycleNum,
|
|
topLeft: topLeft,
|
|
topLeft80Col: topLeft80Col,
|
|
};
|
|
}
|
|
|
|
const NTSC_DETAILS = buildTiming(ntscClockFrequency, ntscDisplayRect,
|
|
ntscVisibleRect, ntscVertTotal, NTSC_FSC);
|
|
const PAL_DETAILS = buildTiming(palClockFrequency, palDisplayRect,
|
|
palVisibleRect, palVertTotal, PAL_FSC);
|
|
|
|
// https://codereview.stackexchange.com/a/128619
|
|
const loadImage = path =>
|
|
new Promise((resolve, reject) => {
|
|
const img = new Image();
|
|
img.onload = () => resolve(img);
|
|
img.onerror = () => reject(`error loading '${path}'`);
|
|
img.src = path;
|
|
});
|
|
|
|
// Given an image that's 560x192, render it into the larger space
|
|
// required for NTSC or PAL.
|
|
// image: a 560x192 image, from the same domain (hence readable).
|
|
// details: NTSC_DETAILS, or PAL_DETAILS
|
|
// returns: a canvas
|
|
const screenData = (image, details, dhgr=true) => {
|
|
if ((image.naturalWidth != 560) || (image.naturalHeight != 192)) {
|
|
throw new Error('screenData expects an image 560x192;' +
|
|
` got ${image.naturalWidth}x${image.naturalHeight}`);
|
|
}
|
|
const canvas = document.createElement('canvas');
|
|
const context = canvas.getContext('2d');
|
|
const width = details.imageSize.width;
|
|
const height = details.imageSize.height;
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
context.fillStyle = 'rgba(0,0,0,1)';
|
|
context.fillRect(0, 0, width, height);
|
|
const topLeft = dhgr ? details.topLeft80Col : details.topLeft;
|
|
context.drawImage(image, topLeft.x, topLeft.y);
|
|
const imageData = context.getImageData(0, 0, width, height);
|
|
return [canvas, imageData];
|
|
};
|
|
|
|
// Given an ImageData (RGBA), convert to luminance by taking the max
|
|
// of (R,G,B) for each pixel. Return a Uint8Array.
|
|
const luminanceData = (imageData) => {
|
|
const width = imageData.width;
|
|
const height = imageData.height;
|
|
const data = imageData.data;
|
|
const size = width * height;
|
|
const ary = new Uint8Array(size);
|
|
for (let i = 0; i < size; i++) {
|
|
ary[i] = Math.max(data[i*4], data[i*4+1], data[i*4+2]);
|
|
}
|
|
return ary;
|
|
};
|
|
|
|
const TEXTURE_NAMES = [
|
|
"SHADOWMASK_TRIAD",
|
|
"SHADOWMASK_INLINE",
|
|
"SHADOWMASK_APERTURE",
|
|
"SHADOWMASK_LCD",
|
|
"SHADOWMASK_BAYER",
|
|
"IMAGE_PHASEINFO",
|
|
"IMAGE_IN",
|
|
"IMAGE_DECODED",
|
|
"IMAGE_PERSISTENCE",
|
|
];
|
|
|
|
const BUFFER_COUNT = 3;
|
|
|
|
const SHADER_NAMES = [
|
|
"COMPOSITE",
|
|
"DISPLAY",
|
|
"RGB",
|
|
];
|
|
|
|
const resizeCanvas = (canvas) => {
|
|
// Lookup the size the browser is displaying the canvas.
|
|
const displayWidth = canvas.clientWidth;
|
|
const displayHeight = canvas.clientHeight;
|
|
|
|
// Check if the canvas is not the same size.
|
|
if (canvas.width != displayWidth ||
|
|
canvas.height != displayHeight) {
|
|
canvas.width = displayWidth;
|
|
canvas.height = displayHeight;
|
|
}
|
|
};
|
|
|
|
// Code from:
|
|
// https://webglfundamentals.org/webgl/lessons/webgl-fundamentals.html
|
|
const createShader = (gl, name, type, source) => {
|
|
const shader = gl.createShader(type);
|
|
gl.shaderSource(shader, source);
|
|
gl.compileShader(shader);
|
|
const success = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
|
|
if (success) {
|
|
return shader;
|
|
}
|
|
|
|
const log = gl.getShaderInfoLog(shader);
|
|
gl.deleteShader(shader);
|
|
throw new Error(`unable to compile shader ${name}: \n${log}`);
|
|
};
|
|
|
|
// Code from:
|
|
// https://webglfundamentals.org/webgl/lessons/webgl-fundamentals.html
|
|
const createProgram = (gl, name, ...shaders) => {
|
|
const program = gl.createProgram();
|
|
for (let shader of shaders) {
|
|
gl.attachShader(program, shader);
|
|
}
|
|
gl.linkProgram(program);
|
|
const success = gl.getProgramParameter(program, gl.LINK_STATUS);
|
|
if (success) {
|
|
return program;
|
|
}
|
|
const log = gl.getProgramInfoLog(program);
|
|
gl.deleteProgram(program);
|
|
throw new Error(`unable to compile program ${name}: \n${log}`);
|
|
};
|
|
|
|
const TextureInfo = class {
|
|
constructor(width, height, glTexture) {
|
|
this.width = width;
|
|
this.height = height;
|
|
this.glTexture = glTexture;
|
|
}
|
|
|
|
get size() {
|
|
return new Size(this.width, this.height);
|
|
}
|
|
};
|
|
|
|
const DisplayConfiguration = class {
|
|
constructor() {
|
|
this.videoDecoder = "CANVAS_YUV";
|
|
this.videoBrightness = 0;
|
|
this.videoContrast = 1;
|
|
this.videoSaturation = 1;
|
|
this.videoHue = 0;
|
|
this.videoCenter = new Point(0, 0);
|
|
this.videoSize = new Size(1.05, 1.05);
|
|
this.videoBandwidth = 6000000; // 14318180;
|
|
this.videoLumaBandwidth = 2000000; // 600000;
|
|
this.videoChromaBandwidth = 600000; // 2000000;
|
|
this.videoWhiteOnly = false;
|
|
|
|
this.displayResolution = new Size(640, 480);
|
|
this.displayPixelDensity = 72;
|
|
this.displayBarrel = 0.05; // 0;
|
|
this.displayScanlineLevel = 0.05; // 0;
|
|
this.displayShadowMaskLevel = 0.05; // 0;
|
|
this.displayShadowMaskDotPitch = 0.5; // 1;
|
|
this.displayShadowMask = "SHADOWMASK_TRIAD";
|
|
this.displayPersistence = 0;
|
|
this.displayCenterLighting = 1;
|
|
this.displayLuminanceGain = 1;
|
|
}
|
|
};
|
|
|
|
// Corresponds to OEImage. Contains the data on an NTSC/PAL/whatever
|
|
// image. The `data` field is an ImageData object with the actual
|
|
// image data.
|
|
const ImageInfo = class {
|
|
constructor(data) {
|
|
if (typeof data != "object") {
|
|
throw new Error(`want typeof data == 'object'; got '${typeof data}'`);
|
|
}
|
|
if (!(data instanceof ImageData)) {
|
|
throw new Error(`want data instanceof ImageData; got '${data.constructor.name}'`);
|
|
}
|
|
|
|
this.sampleRate = NTSC_4FSC;
|
|
this.blackLevel = 0;
|
|
this.whiteLevel = 1;
|
|
this.interlace = 0;
|
|
this.subCarrier = NTSC_FSC;
|
|
this.colorBurst = NTSC_DETAILS.colorBurst;
|
|
this.phaseAlternation = [false];
|
|
this.data = data;
|
|
}
|
|
|
|
get width() {
|
|
return this.data.width;
|
|
}
|
|
|
|
get height() {
|
|
return this.data.height;
|
|
}
|
|
|
|
get size() {
|
|
return new Size(this.data.width, this.data.height);
|
|
}
|
|
};
|
|
|
|
const ScreenView = class {
|
|
constructor(canvas) {
|
|
const gl = canvas.getContext("webgl");
|
|
const float_texture_ext = gl.getExtension('OES_texture_float');
|
|
if (float_texture_ext == null) {
|
|
throw new Error("WebGL extension 'OES_texture_float' unavailable");
|
|
}
|
|
|
|
this.canvas = canvas;
|
|
this.gl = gl;
|
|
this.textures = {};
|
|
this.shaders = {};
|
|
this.buffers = [];
|
|
this.image = null;
|
|
this.display = null;
|
|
this.imageSampleRate = null;
|
|
this.imageBlackLevel = null;
|
|
this.imageWhiteLevel = null;
|
|
this.imageSubcarrier = null;
|
|
this.viewportSize = new Size(0, 0);
|
|
this.persistenceTexRect = new Rect(0, 0, 0, 0);
|
|
|
|
this.configurationChanged = true;
|
|
this.imageChanged = true;
|
|
}
|
|
|
|
get image() {
|
|
return this._image
|
|
}
|
|
|
|
set image(image) {
|
|
this._image = image;
|
|
this.imageChanged = true;
|
|
}
|
|
|
|
set displayConfiguration(displayConfiguration) {
|
|
this.display = displayConfiguration;
|
|
this.configurationChanged = true;
|
|
}
|
|
|
|
async initOpenGL() {
|
|
const gl = this.gl;
|
|
|
|
gl.enable(gl.BLEND);
|
|
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
|
|
|
this.textures = {};
|
|
|
|
for (let name of TEXTURE_NAMES) {
|
|
this.textures[name] = new TextureInfo(0, 0, gl.createTexture());
|
|
}
|
|
|
|
for (let i = 0; i < BUFFER_COUNT; i++) {
|
|
this.buffers.push(gl.createBuffer());
|
|
}
|
|
|
|
await this.loadTextures();
|
|
|
|
gl.pixelStorei(gl.PACK_ALIGNMENT, 1);
|
|
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
|
|
|
|
this.loadShaders();
|
|
}
|
|
|
|
freeOpenGL() {
|
|
const gl = this.gl;
|
|
|
|
for (let name of TEXTURE_NAMES) {
|
|
gl.deleteTexture(this.textures[name].glTexture);
|
|
}
|
|
|
|
for (let buffer of this.buffers) {
|
|
gl.deleteBuffer(buffer);
|
|
}
|
|
|
|
this.deleteShaders();
|
|
}
|
|
|
|
loadTextures() {
|
|
return Promise.all([
|
|
this.loadTexture("textures/Shadow Mask Triad.png", true, "SHADOWMASK_TRIAD"),
|
|
this.loadTexture("textures/Shadow Mask Inline.png", true, "SHADOWMASK_INLINE"),
|
|
this.loadTexture("textures/Shadow Mask Aperture.png", true, "SHADOWMASK_APERTURE"),
|
|
this.loadTexture("textures/Shadow Mask LCD.png", true, "SHADOWMASK_LCD"),
|
|
this.loadTexture("textures/Shadow Mask Bayer.png", true, "SHADOWMASK_BAYER"),
|
|
]);
|
|
}
|
|
|
|
async loadTexture(path, isMipMap, name) {
|
|
const gl = this.gl;
|
|
const texInfo = this.textures[name];
|
|
const image = await loadImage(path);
|
|
gl.bindTexture(gl.TEXTURE_2D, texInfo.glTexture);
|
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA,
|
|
gl.RGBA, gl.UNSIGNED_BYTE, image);
|
|
if (isMipMap) {
|
|
gl.generateMipmap(gl.TEXTURE_2D);
|
|
}
|
|
|
|
texInfo.width = image.naturalWidth;
|
|
texInfo.height = image.naturalHeight;
|
|
}
|
|
|
|
loadShaders() {
|
|
this.loadShader("COMPOSITE", COMPOSITE_SHADER, VERTEX_RENDER_SHADER);
|
|
this.loadShader("RGB", RGB_SHADER, VERTEX_RENDER_SHADER);
|
|
this.loadShader("DISPLAY", DISPLAY_SHADER, VERTEX_DISPLAY_SHADER);
|
|
}
|
|
|
|
loadShader(name, fragmentSource, vertexSource) {
|
|
const glVertexShader = createShader(this.gl, name, this.gl.VERTEX_SHADER, vertexSource);
|
|
const glFragmentShader = createShader(this.gl, name, this.gl.FRAGMENT_SHADER,
|
|
fragmentSource);
|
|
const glProgram = createProgram(this.gl, name, glVertexShader, glFragmentShader);
|
|
this.gl.deleteShader(glVertexShader);
|
|
this.gl.deleteShader(glFragmentShader);
|
|
this.shaders[name] = glProgram;
|
|
}
|
|
|
|
deleteShaders() {
|
|
for (let name of SHADER_NAMES) {
|
|
if (this.shaders[name]) {
|
|
this.gl.deleteProgram(this.shaders[name]);
|
|
this.shaders[name] = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
vsync() {
|
|
const gl = this.gl;
|
|
resizeCanvas(this.canvas);
|
|
const canvasWidth = this.canvas.width;
|
|
const canvasHeight = this.canvas.height;
|
|
|
|
// if viewport size has changed:
|
|
if ((this.viewportSize.width != canvasWidth)
|
|
|| (this.viewportSize.height != this.canvasHeight)) {
|
|
this.viewportSize = new Size(canvasWidth, canvasHeight);
|
|
gl.viewport(0, 0, canvasWidth, canvasHeight);
|
|
this.configurationChanged = true;
|
|
}
|
|
|
|
if (this.imageChanged) {
|
|
this.uploadImage();
|
|
}
|
|
|
|
if (this.configurationChanged) {
|
|
this.configureShaders();
|
|
}
|
|
|
|
if (this.imageChanged || this.configurationChanged) {
|
|
this.renderImage();
|
|
}
|
|
|
|
if (this.imageChanged || this.configurationChanged ||
|
|
this.image.displayPersistence != 0) {
|
|
this.drawDisplayCanvas();
|
|
}
|
|
}
|
|
|
|
uploadImage() {
|
|
const gl = this.gl;
|
|
const image = this.image;
|
|
|
|
this.resizeTexture("IMAGE_IN", image.width, image.height, true);
|
|
const texInfoImage = this.textures["IMAGE_IN"];
|
|
gl.bindTexture(gl.TEXTURE_2D, texInfoImage.glTexture);
|
|
const format = gl.LUMINANCE;
|
|
const type = gl.UNSIGNED_BYTE;
|
|
const luminance = luminanceData(image.data);
|
|
gl.texSubImage2D(gl.TEXTURE_2D, 0,
|
|
0, 0, // xoffset, yoffset
|
|
image.data.width,
|
|
image.data.height,
|
|
format, type, luminance);
|
|
|
|
// Update configuration
|
|
if ((image.sampleRate != this.imageSampleRate) ||
|
|
(image.blackLevel != this.imageBlackLevel) ||
|
|
(image.whiteLevel != this.imageWhiteLevel) ||
|
|
(image.subCarrier != this.imageSubcarrier))
|
|
{
|
|
this.imageSampleRate = image.sampleRate;
|
|
this.imageBlackLevel = image.blackLevel;
|
|
this.imageWhiteLevel = image.whiteLevel;
|
|
this.imageSubcarrier = image.subCarrier;
|
|
|
|
this.configurationChanged = true;
|
|
}
|
|
|
|
// Upload phase info
|
|
const texHeight = 2**Math.ceil(Math.log2(image.height));
|
|
const colorBurst = image.colorBurst
|
|
const phaseAlternation = image.phaseAlternation;
|
|
|
|
const phaseInfo = new Float32Array(3 * texHeight);
|
|
|
|
for (let x = 0; x < image.height; x++) {
|
|
const c = colorBurst[x % colorBurst.length] / 2 / Math.PI;
|
|
phaseInfo[3 * x + 0] = c - Math.floor(c);
|
|
phaseInfo[3 * x + 1] = phaseAlternation[x % phaseAlternation.length];
|
|
}
|
|
|
|
const texInfoPhase = this.textures["IMAGE_PHASEINFO"];
|
|
gl.bindTexture(gl.TEXTURE_2D, texInfoPhase.glTexture);
|
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, 1, texHeight, 0,
|
|
gl.RGB, gl.FLOAT, phaseInfo);
|
|
}
|
|
|
|
getRenderShader() {
|
|
switch (this.display.videoDecoder) {
|
|
case "CANVAS_RGB":
|
|
case "CANVAS_MONOCHROME":
|
|
return [this.shaders["RGB"], "RGB"];
|
|
case "CANVAS_YUV":
|
|
case "CANVAS_YIQ":
|
|
case "CANVAS_CXA2025AS":
|
|
return [this.shaders["COMPOSITE"], "COMPOSITE"];
|
|
}
|
|
const decoder = this.display.videoDecoder;
|
|
throw new Error(`unknown displayConfiguration.videoDecoder: ${decoder}`);
|
|
}
|
|
|
|
configureShaders() {
|
|
const gl = this.gl;
|
|
|
|
const [renderShader, renderShaderName] = this.getRenderShader();
|
|
const displayShader = this.shaders["DISPLAY"];
|
|
|
|
const isCompositeDecoder = (renderShaderName == "COMPOSITE");
|
|
|
|
// Render shader
|
|
gl.useProgram(renderShader);
|
|
|
|
// Subcarrier
|
|
if (isCompositeDecoder) {
|
|
gl.uniform1f(gl.getUniformLocation(renderShader, "subcarrier"),
|
|
this.imageSubcarrier / this.imageSampleRate);
|
|
}
|
|
|
|
// Filters
|
|
const w = Vector.chebyshevWindow(17, 50).normalize();
|
|
|
|
let wy, wu, wv;
|
|
|
|
const bandwidth = this.display.videoBandwidth / this.imageSampleRate;
|
|
|
|
if (isCompositeDecoder) {
|
|
let yBandwidth = this.display.videoLumaBandwidth / this.imageSampleRate;
|
|
let uBandwidth = this.display.videoChromaBandwidth / this.imageSampleRate;
|
|
let vBandwidth = uBandwidth;
|
|
|
|
if (this.display.videoDecoder == "CANVAS_YIQ")
|
|
uBandwidth = uBandwidth + NTSC_IQ_DELTA / this.imageSampleRate;
|
|
|
|
// Switch to video bandwidth when no subcarrier
|
|
if ((this.imageSubcarrier == 0.0) || this.display.videoWhiteOnly)
|
|
{
|
|
yBandwidth = bandwidth;
|
|
uBandwidth = bandwidth;
|
|
vBandwidth = bandwidth;
|
|
}
|
|
|
|
wy = w.mul(Vector.lanczosWindow(17, yBandwidth));
|
|
wy = wy.normalize();
|
|
|
|
wu = w.mul(Vector.lanczosWindow(17, uBandwidth));
|
|
wu = wu.normalize().mul(2);
|
|
|
|
wv = w.mul(Vector.lanczosWindow(17, vBandwidth));
|
|
wv = wv.normalize().mul(2);
|
|
} else {
|
|
wy = w.mul(Vector.lanczosWindow(17, bandwidth));
|
|
wu = wv = wy = wy.normalize();
|
|
}
|
|
|
|
gl.uniform3f(gl.getUniformLocation(renderShader, "c0"),
|
|
wy.data[8], wu.data[8], wv.data[8]);
|
|
gl.uniform3f(gl.getUniformLocation(renderShader, "c1"),
|
|
wy.data[7], wu.data[7], wv.data[7]);
|
|
gl.uniform3f(gl.getUniformLocation(renderShader, "c2"),
|
|
wy.data[6], wu.data[6], wv.data[6]);
|
|
gl.uniform3f(gl.getUniformLocation(renderShader, "c3"),
|
|
wy.data[5], wu.data[5], wv.data[5]);
|
|
gl.uniform3f(gl.getUniformLocation(renderShader, "c4"),
|
|
wy.data[4], wu.data[4], wv.data[4]);
|
|
gl.uniform3f(gl.getUniformLocation(renderShader, "c5"),
|
|
wy.data[3], wu.data[3], wv.data[3]);
|
|
gl.uniform3f(gl.getUniformLocation(renderShader, "c6"),
|
|
wy.data[2], wu.data[2], wv.data[2]);
|
|
gl.uniform3f(gl.getUniformLocation(renderShader, "c7"),
|
|
wy.data[1], wu.data[1], wv.data[1]);
|
|
gl.uniform3f(gl.getUniformLocation(renderShader, "c8"),
|
|
wy.data[0], wu.data[0], wv.data[0]);
|
|
|
|
// Decoder matrix
|
|
let decoderMatrix = new Matrix3(1, 0, 0,
|
|
0, 1, 0,
|
|
0, 0, 1);
|
|
|
|
// Encode
|
|
if (!isCompositeDecoder) {
|
|
// Y'PbPr encoding matrix
|
|
decoderMatrix = new Matrix3(0.299, -0.168736, 0.5,
|
|
0.587, -0.331264, -0.418688,
|
|
0.114, 0.5, -0.081312).mul(decoderMatrix);
|
|
}
|
|
|
|
// Set hue
|
|
if (this.display.videoDecoder == "CANVAS_MONOCHROME")
|
|
decoderMatrix = new Matrix3(1, 0.5, 0,
|
|
0, 0, 0,
|
|
0, 0, 0).mul(decoderMatrix);
|
|
|
|
// Disable color decoding when no subcarrier
|
|
if (isCompositeDecoder)
|
|
{
|
|
if ((this.imageSubcarrier == 0.0) || this.display.videoWhiteOnly) {
|
|
decoderMatrix = new Matrix3(1, 0, 0,
|
|
0, 0, 0,
|
|
0, 0, 0).mul(decoderMatrix);
|
|
}
|
|
}
|
|
|
|
// Saturation
|
|
decoderMatrix = new Matrix3(1, 0, 0,
|
|
0, this.display.videoSaturation, 0,
|
|
0, 0, this.display.videoSaturation).mul(decoderMatrix);
|
|
|
|
// Hue
|
|
let hue = 2 * Math.PI * this.display.videoHue;
|
|
|
|
decoderMatrix = new Matrix3(1, 0, 0,
|
|
0, Math.cos(hue), -Math.sin(hue),
|
|
0, Math.sin(hue), Math.cos(hue)).mul(decoderMatrix);
|
|
|
|
// Decode
|
|
switch (this.display.videoDecoder) {
|
|
case "CANVAS_RGB":
|
|
case "CANVAS_MONOCHROME":
|
|
// Y'PbPr decoder matrix
|
|
decoderMatrix = new Matrix3(1, 1, 1,
|
|
0, -0.344136, 1.772,
|
|
1.402, -0.714136, 0).mul(decoderMatrix);
|
|
break;
|
|
|
|
case "CANVAS_YUV":
|
|
case "CANVAS_YIQ":
|
|
// Y'UV decoder matrix
|
|
decoderMatrix = new Matrix3(1, 1, 1,
|
|
0, -0.394642, 2.032062,
|
|
1.139883, -0.580622, 0).mul(decoderMatrix);
|
|
break;
|
|
|
|
case "CANVAS_CXA2025AS":
|
|
// Exchange I and Q
|
|
decoderMatrix = new Matrix3(1, 0, 0,
|
|
0, 0, 1,
|
|
0, 1, 0).mul(decoderMatrix);
|
|
|
|
// Rotate 33 degrees
|
|
hue = -Math.PI * 33 / 180;
|
|
decoderMatrix = new Matrix3(1, 0, 0,
|
|
0, Math.cos(hue), -Math.sin(hue),
|
|
0, Math.sin(hue), Math.cos(hue)).mul(decoderMatrix);
|
|
|
|
// CXA2025AS decoder matrix
|
|
decoderMatrix = new Matrix3(1, 1, 1,
|
|
1.630, -0.378, -1.089,
|
|
0.317, -0.466, 1.677).mul(decoderMatrix);
|
|
break;
|
|
default:
|
|
throw new Error(`unknown videoDecoder: ${this.display.videoDecoder}`);
|
|
}
|
|
|
|
// Brightness
|
|
const brightness = this.display.videoBrightness - this.imageBlackLevel;
|
|
let decoderOffset;
|
|
|
|
if (isCompositeDecoder)
|
|
decoderOffset = decoderMatrix.mul(new Matrix3(brightness, 0, 0,
|
|
0, 0, 0,
|
|
0, 0, 0));
|
|
else
|
|
decoderOffset = decoderMatrix.mul(new Matrix3(brightness, 0, 0,
|
|
brightness, 0, 0,
|
|
brightness, 0, 0));
|
|
|
|
gl.uniform3f(gl.getUniformLocation(renderShader, "decoderOffset"),
|
|
decoderOffset.at(0, 0),
|
|
decoderOffset.at(0, 1),
|
|
decoderOffset.at(0, 2));
|
|
|
|
// Contrast
|
|
let contrast = this.display.videoContrast;
|
|
|
|
const videoLevel = (this.imageWhiteLevel - this.imageBlackLevel);
|
|
if (videoLevel > 0)
|
|
contrast /= videoLevel;
|
|
else
|
|
contrast = 0;
|
|
|
|
if (contrast < 0)
|
|
contrast = 0;
|
|
|
|
decoderMatrix = decoderMatrix.mul(contrast);
|
|
|
|
gl.uniformMatrix3fv(gl.getUniformLocation(renderShader, "decoderMatrix"),
|
|
false, decoderMatrix.data);
|
|
|
|
// Display shader
|
|
gl.useProgram(displayShader);
|
|
|
|
// Barrel
|
|
gl.uniform1f(gl.getUniformLocation(displayShader, "barrel"),
|
|
this.display.displayBarrel);
|
|
|
|
// Shadow mask
|
|
gl.uniform1i(gl.getUniformLocation(displayShader, "shadowMask"), 1);
|
|
gl.uniform1f(gl.getUniformLocation(displayShader, "shadowMaskLevel"),
|
|
this.display.displayShadowMaskLevel);
|
|
|
|
// Persistence
|
|
const frameRate = 60;
|
|
|
|
gl.uniform1f(gl.getUniformLocation(displayShader, "persistenceLevel"),
|
|
this.display.displayPersistence /
|
|
(1.0 / frameRate + this.display.displayPersistence));
|
|
|
|
if (this.display.displayPersistence == 0)
|
|
this.resizeTexture("IMAGE_PERSISTENCE", 0, 0);
|
|
|
|
// Center lighting
|
|
let centerLighting = this.display.displayCenterLighting;
|
|
if (Math.abs(centerLighting) < 0.001)
|
|
centerLighting = 0.001;
|
|
gl.uniform1f(gl.getUniformLocation(displayShader, "centerLighting"),
|
|
1.0 / centerLighting - 1);
|
|
|
|
// Luminance gain
|
|
gl.uniform1f(gl.getUniformLocation(displayShader, "luminanceGain"),
|
|
this.display.displayLuminanceGain);
|
|
}
|
|
|
|
renderImage() {
|
|
const gl = this.gl;
|
|
const [renderShader, renderShaderName] = this.getRenderShader();
|
|
|
|
const isCompositeDecoder = (renderShaderName == "COMPOSITE");
|
|
|
|
gl.useProgram(renderShader);
|
|
|
|
const texSize = this.textures["IMAGE_IN"].size;
|
|
this.resizeTexture("IMAGE_DECODED", texSize.width, texSize.height);
|
|
|
|
gl.uniform1i(gl.getUniformLocation(renderShader, "texture"), 0);
|
|
gl.uniform2f(gl.getUniformLocation(renderShader, "textureSize"),
|
|
texSize.width, texSize.height);
|
|
|
|
if (isCompositeDecoder)
|
|
{
|
|
gl.uniform1i(gl.getUniformLocation(renderShader, "phaseInfo"), 1);
|
|
|
|
gl.activeTexture(gl.TEXTURE1);
|
|
|
|
gl.bindTexture(gl.TEXTURE_2D, this.textures["IMAGE_PHASEINFO"].glTexture);
|
|
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
|
|
|
gl.activeTexture(gl.TEXTURE0);
|
|
}
|
|
|
|
// Render to the back buffer, to avoid using FBOs
|
|
// (support for vanilla OpenGL 2.0 cards)
|
|
|
|
// I think webgl is rendering to the back buffer anyway, until
|
|
// we stop executing javascript statements and give control
|
|
// back, at which point it flips. So we might not need
|
|
// this. Although truly, I'm not certain what it's doing. If we
|
|
// *do* end up needing it, we'll have to go full webgl2.
|
|
|
|
// glReadBuffer(GL_BACK);
|
|
|
|
const imageSize = this.image.size;
|
|
|
|
for (let y = 0; y < this.image.height; y += this.viewportSize.height) {
|
|
for (let x = 0; x < this.image.width; x += this.viewportSize.width) {
|
|
// Calculate rects
|
|
const clipSize = this.viewportSize.copy();
|
|
|
|
if ((x + clipSize.width) > imageSize.width)
|
|
clipSize.width = imageSize.width - x;
|
|
if ((y + clipSize.height) > imageSize.height)
|
|
clipSize.height = imageSize.height - y;
|
|
const textureRect = new Rect(x / texSize.width,
|
|
y / texSize.height,
|
|
clipSize.width / texSize.width,
|
|
clipSize.height / texSize.height);
|
|
const canvasRect = new Rect(-1,
|
|
-1,
|
|
2 * clipSize.width / this.viewportSize.width,
|
|
2 * clipSize.height / this.viewportSize.height);
|
|
|
|
// Render
|
|
gl.bindTexture(gl.TEXTURE_2D, this.textures["IMAGE_IN"].glTexture);
|
|
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
|
|
|
// What is this, some ancient fixed pipeline nonsense? No way.
|
|
// glLoadIdentity();
|
|
|
|
this.drawRectangle(renderShader, canvasRect, textureRect);
|
|
|
|
// Copy framebuffer
|
|
gl.bindTexture(gl.TEXTURE_2D, this.textures["IMAGE_DECODED"].glTexture);
|
|
|
|
gl.copyTexSubImage2D(gl.TEXTURE_2D, 0,
|
|
x, y, 0, 0,
|
|
clipSize.width, clipSize.height);
|
|
}
|
|
}
|
|
}
|
|
|
|
drawRectangle(shader, posRect, texRect, texRect2) {
|
|
const gl = this.gl;
|
|
|
|
gl.clearColor(0, 0, 0, 0);
|
|
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
|
|
|
const positionLocation = gl.getAttribLocation(shader, "a_position");
|
|
const texcoordLocations = [gl.getAttribLocation(shader, "a_texCoord")];
|
|
const texRects = [texRect];
|
|
if (texRect2) {
|
|
texcoordLocations.push(gl.getAttribLocation(shader, "a_texCoord2"));
|
|
texRects.push(texRect2);
|
|
}
|
|
|
|
const positionBuffer = this.buffers[0];
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
|
const p_x1 = posRect.l;
|
|
const p_x2 = posRect.r;
|
|
const p_y1 = posRect.t;
|
|
const p_y2 = posRect.b;
|
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
|
|
p_x1, p_y1,
|
|
p_x2, p_y1,
|
|
p_x1, p_y2,
|
|
p_x1, p_y2,
|
|
p_x2, p_y1,
|
|
p_x2, p_y2,
|
|
]), gl.STATIC_DRAW);
|
|
|
|
gl.enableVertexAttribArray(positionLocation);
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
|
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
|
|
|
|
for (let i = 0; i < texRects.length; i++) {
|
|
const texcoordBuffer = this.buffers[i+1];
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer);
|
|
const t_x1 = texRects[i].l;
|
|
const t_x2 = texRects[i].r;
|
|
const t_y1 = texRects[i].t;
|
|
const t_y2 = texRects[i].b;
|
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
|
|
t_x1, t_y1,
|
|
t_x2, t_y1,
|
|
t_x1, t_y2,
|
|
t_x1, t_y2,
|
|
t_x2, t_y1,
|
|
t_x2, t_y2,
|
|
]), gl.STATIC_DRAW);
|
|
|
|
gl.enableVertexAttribArray(texcoordLocations[i]);
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer);
|
|
gl.vertexAttribPointer(texcoordLocations[i], 2, gl.FLOAT, false, 0, 0);
|
|
}
|
|
|
|
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
|
}
|
|
|
|
drawDisplayCanvas() {
|
|
const gl = this.gl;
|
|
|
|
const displayShader = this.shaders["DISPLAY"];
|
|
|
|
// Clear
|
|
// (Moved inside drawRectangle)
|
|
// gl.clearColor(0, 0, 0, 1);
|
|
// gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
|
|
|
if (this.image.width == 0 || this.image.height == 0) {
|
|
this.resizeTexture("IMAGE_PERSISTENCE", 0, 0);
|
|
return;
|
|
}
|
|
|
|
// Grab common variables
|
|
const displayResolution = this.display.displayResolution;
|
|
|
|
// Vertex rect
|
|
const vertexRect = new Rect(-1, -1, 2, 2);
|
|
|
|
const viewportAspectRatio = this.viewportSize.ratio;
|
|
const displayAspectRatio = displayResolution.ratio;
|
|
|
|
const ratio = viewportAspectRatio / displayAspectRatio;
|
|
|
|
if (ratio > 1) {
|
|
vertexRect.origin.x /= ratio;
|
|
vertexRect.size.width /= ratio;
|
|
} else {
|
|
vertexRect.origin.y *= ratio;
|
|
vertexRect.size.height *= ratio;
|
|
}
|
|
|
|
// Base texture rect
|
|
const baseTexRect = new Rect(0, 0, 1, 1);
|
|
|
|
// Canvas texture rect
|
|
const interlaceShift = this.image.interlace / this.image.height;
|
|
|
|
const canvasTexLowerLeft = this.getDisplayCanvasTexPoint(
|
|
new Point(-1, -1 + 2 * interlaceShift));
|
|
const canvasTexUpperRight = this.getDisplayCanvasTexPoint(
|
|
new Point(1, 1 + 2 * interlaceShift));
|
|
|
|
const canvasTexRect = new Rect(canvasTexLowerLeft.x,
|
|
canvasTexLowerLeft.y,
|
|
canvasTexUpperRight.x - canvasTexLowerLeft.x,
|
|
canvasTexUpperRight.y - canvasTexLowerLeft.y);
|
|
|
|
const canvasSize = new Size(0.5 * this.viewportSize.width *
|
|
vertexRect.size.width,
|
|
0.5 * this.viewportSize.height *
|
|
vertexRect.size.height);
|
|
|
|
const canvasVideoSize = new Size(canvasSize.width *
|
|
this.display.videoSize.width,
|
|
canvasSize.height *
|
|
this.display.videoSize.height);
|
|
|
|
let barrelTexRect;
|
|
|
|
// Render
|
|
const texture = this.textures["IMAGE_DECODED"];
|
|
|
|
// Set uniforms
|
|
gl.useProgram(displayShader);
|
|
|
|
// Texture
|
|
const texSize = texture.size;
|
|
|
|
gl.uniform1i(gl.getUniformLocation(displayShader, "texture"), 0);
|
|
gl.uniform2f(gl.getUniformLocation(displayShader, "textureSize"),
|
|
texSize.width, texSize.height);
|
|
|
|
// Barrel
|
|
barrelTexRect = new Rect(-0.5, -0.5 / displayAspectRatio,
|
|
1.0, 1.0 / displayAspectRatio);
|
|
gl.uniform2f(gl.getUniformLocation(displayShader, "barrelSize"),
|
|
1, 1.0 / displayAspectRatio);
|
|
|
|
// Scanlines
|
|
const scanlineHeight = canvasVideoSize.height / this.image.height;
|
|
let scanlineLevel = this.display.displayScanlineLevel;
|
|
|
|
scanlineLevel = ((scanlineHeight > 2.5) ? scanlineLevel :
|
|
(scanlineHeight < 2) ? 0 :
|
|
(scanlineHeight - 2) / (2.5 - 2) * scanlineLevel);
|
|
|
|
gl.uniform1f(gl.getUniformLocation(displayShader, "scanlineLevel"), scanlineLevel);
|
|
|
|
// Shadow mask
|
|
let shadowMaskTexture;
|
|
let shadowMaskAspectRatio;
|
|
switch (this.display.displayShadowMask)
|
|
{
|
|
case "SHADOWMASK_TRIAD":
|
|
shadowMaskTexture = this.textures["SHADOWMASK_TRIAD"];
|
|
shadowMaskAspectRatio = 2 / (274.0 / 240.0);
|
|
break;
|
|
case "SHADOWMASK_INLINE":
|
|
shadowMaskTexture = this.textures["SHADOWMASK_INLINE"];
|
|
shadowMaskAspectRatio = 2;
|
|
break;
|
|
case "SHADOWMASK_APERTURE":
|
|
shadowMaskTexture = this.textures["SHADOWMASK_APERTURE"];
|
|
shadowMaskAspectRatio = 2;
|
|
break;
|
|
case "SHADOWMASK_LCD":
|
|
shadowMaskTexture = this.textures["SHADOWMASK_LCD"];
|
|
shadowMaskAspectRatio = 2;
|
|
break;
|
|
case "SHADOWMASK_BAYER":
|
|
shadowMaskTexture = this.textures["SHADOWMASK_BAYER"];
|
|
shadowMaskAspectRatio = 2;
|
|
break;
|
|
}
|
|
|
|
let shadowMaskDotPitch = this.display.displayShadowMaskDotPitch;
|
|
|
|
if (shadowMaskDotPitch <= 0.001)
|
|
shadowMaskDotPitch = 0.001;
|
|
|
|
const shadowMaskElemX = (displayResolution.width /
|
|
this.display.displayPixelDensity *
|
|
25.4 * 0.5 / shadowMaskDotPitch);
|
|
const shadowMaskSize = new Size(shadowMaskElemX,
|
|
shadowMaskElemX * shadowMaskAspectRatio /
|
|
displayAspectRatio);
|
|
|
|
gl.activeTexture(gl.TEXTURE1);
|
|
|
|
gl.bindTexture(gl.TEXTURE_2D, shadowMaskTexture.glTexture);
|
|
|
|
gl.uniform2f(gl.getUniformLocation(displayShader, "shadowMaskSize"),
|
|
shadowMaskSize.width, shadowMaskSize.height);
|
|
|
|
// Persistence
|
|
gl.activeTexture(gl.TEXTURE2);
|
|
|
|
gl.bindTexture(gl.TEXTURE_2D, this.textures["IMAGE_PERSISTENCE"].glTexture);
|
|
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
|
|
|
gl.activeTexture(gl.TEXTURE0);
|
|
|
|
gl.uniform1i(gl.getUniformLocation(displayShader, "persistence"), 2);
|
|
gl.uniform2f(gl.getUniformLocation(displayShader, "persistenceOrigin"),
|
|
this.persistenceTexRect.x, this.persistenceTexRect.y);
|
|
gl.uniform2f(gl.getUniformLocation(displayShader, "persistenceSize"),
|
|
this.persistenceTexRect.width, this.persistenceTexRect.height);
|
|
|
|
// Old fixed pipeline stuff.
|
|
// gl.loadIdentity();
|
|
// gl.rotatef(180, 1, 0, 0);
|
|
|
|
gl.bindTexture(gl.TEXTURE_2D, texture.glTexture);
|
|
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
|
|
|
|
// Render
|
|
this.drawRectangle(displayShader, vertexRect, canvasTexRect, baseTexRect);
|
|
|
|
// TODO(zellyn): implement
|
|
/*
|
|
if (displayConfiguration.displayPersistence != 0.0)
|
|
{
|
|
updateTextureSize(OPENGLCANVAS_IMAGE_PERSISTENCE, viewportSize);
|
|
|
|
gl.bindTexture(gl.TEXTURE_2D, texture[OPENGLCANVAS_IMAGE_PERSISTENCE]);
|
|
|
|
gl.readBuffer(gl.BACK);
|
|
|
|
gl.copyTexSubImage2D(gl.TEXTURE_2D, 0,
|
|
0, 0, 0, 0,
|
|
viewportSize.width, viewportSize.height);
|
|
|
|
OESize persistenceTexSize = OEMakeSize(viewportSize.width /
|
|
textureSize[OPENGLCANVAS_IMAGE_PERSISTENCE].width,
|
|
viewportSize.height /
|
|
textureSize[OPENGLCANVAS_IMAGE_PERSISTENCE].height);
|
|
persistenceTexRect = OEMakeRect((vertexRect.origin.x + 1) * 0.5F * persistenceTexSize.width,
|
|
(vertexRect.origin.y + 1) * 0.5F * persistenceTexSize.height,
|
|
vertexRect.size.width * 0.5F * persistenceTexSize.width,
|
|
vertexRect.size.height * 0.5F * persistenceTexSize.height);
|
|
|
|
persistenceTexRect.origin.y += persistenceTexRect.size.height;
|
|
persistenceTexRect.size.height = -persistenceTexRect.size.height;
|
|
}
|
|
*/
|
|
|
|
}
|
|
|
|
getDisplayCanvasTexPoint(p) {
|
|
const videoCenter = this.display.videoCenter;
|
|
const videoSize = this.display.videoSize;
|
|
|
|
p = new Point((p.x - 2 * videoCenter.x) / videoSize.width,
|
|
(p.y - 2 * videoCenter.y) / videoSize.height);
|
|
|
|
const imageSize = this.image.size;
|
|
const texSize = this.textures["IMAGE_IN"].size;
|
|
|
|
p.x = (p.x + 1) * 0.5 * imageSize.width / texSize.width;
|
|
p.y = (p.y + 1) * 0.5 * imageSize.height / texSize.height;
|
|
|
|
return p;
|
|
}
|
|
|
|
// Resize the texture with the given name to the next
|
|
// highest power of two width and height. Wouldn't be
|
|
// necessary with webgl2.
|
|
resizeTexture(name, width, height, luminance=false) {
|
|
const gl = this.gl;
|
|
const texInfo = this.textures[name];
|
|
if (!texInfo) {
|
|
throw new Error(`Cannot find texture named ${name}`);
|
|
}
|
|
if (width < 4) width = 4;
|
|
if (height < 4) height = 4;
|
|
width = 2**Math.ceil(Math.log2(width));
|
|
height = 2**Math.ceil(Math.log2(height));
|
|
if (texInfo.width != width || texInfo.height != height) {
|
|
texInfo.width = width;
|
|
texInfo.height = height;
|
|
gl.bindTexture(gl.TEXTURE_2D, texInfo.glTexture);
|
|
const length = width * height * (luminance ? 1 : 4);
|
|
const dummy = new Uint8Array(length);
|
|
const type = luminance ? gl.LUMINANCE : gl.RGBA;
|
|
gl.texImage2D(gl.TEXTURE_2D, 0, type, width, height, 0,
|
|
type, gl.UNSIGNED_BYTE, dummy);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
C: {
|
|
HORIZ_START: HORIZ_START,
|
|
HORIZ_BLANK: HORIZ_BLANK,
|
|
HORIZ_DISPLAY: HORIZ_DISPLAY,
|
|
HORIZ_TOTAL: HORIZ_TOTAL,
|
|
CELL_WIDTH: CELL_WIDTH,
|
|
CELL_HEIGHT: CELL_HEIGHT,
|
|
VERT_NTSC_START: VERT_NTSC_START,
|
|
VERT_PAL_START: VERT_PAL_START,
|
|
VERT_DISPLAY: VERT_DISPLAY,
|
|
BLOCK_WIDTH: BLOCK_WIDTH,
|
|
BLOCK_HEIGHT: BLOCK_HEIGHT,
|
|
NTSC_I_CUTOFF: NTSC_I_CUTOFF,
|
|
NTSC_Q_CUTOFF: NTSC_Q_CUTOFF,
|
|
NTSC_IQ_DELTA: NTSC_IQ_DELTA,
|
|
NTSC_DETAILS: NTSC_DETAILS,
|
|
PAL_DETAILS: PAL_DETAILS,
|
|
},
|
|
loadImage: loadImage,
|
|
screenData: screenData,
|
|
resizeCanvas: resizeCanvas,
|
|
createShader: createShader,
|
|
createProgram: createProgram,
|
|
|
|
// Classes.
|
|
ScreenView: ScreenView,
|
|
DisplayConfiguration: DisplayConfiguration,
|
|
ImageInfo: ImageInfo,
|
|
Vector: Vector,
|
|
Size: Size,
|
|
Point: Point,
|
|
};
|
|
})();
|