Published
Edited
Apr 22, 2021
Insert cell
Insert cell
Insert cell
Insert cell
w = Vector.chebyshevWindow(17, 50).normalize()
Insert cell
data = Array.from(w.data).map((d, i) => ({'name': i, 'value': d}))
Insert cell
Type JavaScript, then Shift-Enter. Ctrl-space for more options. Arrow ↑/↓ to switch modes.

Insert cell
chart = {
const svg = d3.create("svg")
.attr("viewBox", [0, 0, width, height]);

svg.append("g")
.attr("fill", color)
.selectAll("rect")
.data(data)
.join("rect")
.attr("x", (d, i) => x(i))
.attr("y", d => y(d.value))
.attr("height", d => y(0) - y(d.value))
.attr("width", x.bandwidth());

svg.append("g")
.call(xAxis);

svg.append("g")
.call(yAxis);

return svg.node();
}
Insert cell
x = d3.scaleBand()
.domain(d3.range(data.length))
.range([margin.left, width - margin.right])
.padding(0.1)
Insert cell
y = d3.scaleLinear()
.domain([0, d3.max(data, d => d.value)]).nice()
.range([height - margin.bottom, margin.top])
Insert cell
xAxis = g => g
.attr("transform", `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(x).tickFormat(i => data[i].name).tickSizeOuter(0))
Insert cell
yAxis = g => g
.attr("transform", `translate(${margin.left},0)`)
.call(d3.axisLeft(y).ticks(null, data.format))
.call(g => g.select(".domain").remove())
.call(g => g.append("text")
.attr("x", -margin.left)
.attr("y", 10)
.attr("fill", "currentColor")
.attr("text-anchor", "start")
.text(data.y))
Insert cell
color = "steelBlue"
Insert cell
height = 500
Insert cell
margin = ({top: 30, right: 0, bottom: 30, left: 40})
Insert cell
d3 = require("d3@6")
Insert cell
class Vector {
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;
}

}
Insert cell
class Matrix3 {
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;
}
}
Insert cell
Point = class {
constructor(x, y) {
this.x = x;
this.y = y;
}
}

Insert cell
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;
}
}
Insert cell
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;
}
}
Insert cell
Insert cell
HORIZ_START = 16
Insert cell
HORIZ_BLANK = (9 + HORIZ_START)
Insert cell
HORIZ_DISPLAY = 40;
Insert cell
HORIZ_TOTAL = (HORIZ_BLANK + HORIZ_DISPLAY)
Insert cell
CELL_WIDTH = 14
Insert cell
CELL_HEIGHT = 8
Insert cell
VERT_NTSC_START = 38
Insert cell
VERT_PAL_START = 48
Insert cell
VERT_DISPLAY = 192
Insert cell
Insert cell
BLOCK_HEIGHT = VERT_DISPLAY / CELL_HEIGHT
Insert cell
Insert cell
NTSC_FSC = 315/88 * 1e6 // 3579545 = 3.5 Mhz: Color Subcarrier
Insert cell
NTSC_4FSC = 4 * NTSC_FSC // 14318180 = 14.3 Mhz
Insert cell
NTSC_HTOTAL = (63+5/9) * 1e-6
Insert cell
NTSC_HLENGTH = (52+8/9) * 1e-6
Insert cell
NTSC_HHALF = (35+2/3) * 1e-6
Insert cell
NTSC_HSTART = NTSC_HHALF - NTSC_HLENGTH/2
Insert cell
NTSC_HEND = NTSC_HHALF + NTSC_HLENGTH/2
Insert cell
NTSC_VTOTAL = 262
Insert cell
NTSC_VLENGTH = 240
Insert cell
NTSC_VSTART = 19
Insert cell
NTSC_VEND = NTSC_VSTART + NTSC_VLENGTH;
Insert cell
PAL_FSC = 4433618.75; // Color subcarrier
Insert cell
PAL_4FSC = 4 * PAL_FSC
Insert cell
PAL_HTOTAL = 64e-6
Insert cell
PAL_HLENGTH = 52e-6
Insert cell
PAL_HHALF = (37+10/27) * 1e-6
Insert cell
PAL_HSTART = PAL_HHALF - PAL_HLENGTH / 2
Insert cell
PAL_HEND = PAL_HHALF + PAL_HLENGTH / 2
Insert cell
PAL_VTOTAL = 312
Insert cell
PAL_VLENGTH = 288
Insert cell
PAL_VSTART = 21
Insert cell
PAL_VEND = PAL_VSTART + PAL_VLENGTH
Insert cell
Insert cell
NTSC_I_CUTOFF = 1300000
Insert cell
NTSC_Q_CUTOFF = 600000
Insert cell
NTSC_IQ_DELTA = NTSC_I_CUTOFF - NTSC_Q_CUTOFF
Insert cell
Insert cell
ntscClockFrequency = NTSC_4FSC * HORIZ_TOTAL / 912
Insert cell
ntscVisibleRect = new Rect(ntscClockFrequency * NTSC_HSTART, NTSC_VSTART, ntscClockFrequency * NTSC_HLENGTH, NTSC_VLENGTH)
Insert cell
ntscDisplayRect = new Rect(HORIZ_START, VERT_NTSC_START, HORIZ_DISPLAY, VERT_DISPLAY)
Insert cell
ntscVertTotal = NTSC_VTOTAL
Insert cell
palClockFrequency = 14250450.0 * HORIZ_TOTAL / 912
Insert cell
palVisibleRect = new Rect(palClockFrequency * PAL_HSTART, PAL_VSTART, palClockFrequency * PAL_HLENGTH, PAL_VLENGTH)
Insert cell
palDisplayRect = new Rect(HORIZ_START, VERT_PAL_START, HORIZ_DISPLAY, VERT_DISPLAY)
Insert cell
palVertTotal = PAL_VTOTAL
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
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,
};
}

Insert cell
NTSC_DETAILS = buildTiming(ntscClockFrequency, ntscDisplayRect,
ntscVisibleRect, ntscVertTotal, NTSC_FSC)
Insert cell
PAL_DETAILS = buildTiming(palClockFrequency, palDisplayRect,
palVisibleRect, palVertTotal, PAL_FSC)
Insert cell
// 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
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];
}
Insert cell
// Given an ImageData (RGBA), convert to luminance by taking the max
// of (R,G,B) for each pixel. Return a Uint8Array.
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;
}
Insert cell
TEXTURE_NAMES = [
"SHADOWMASK_TRIAD",
"SHADOWMASK_INLINE",
"SHADOWMASK_APERTURE",
"SHADOWMASK_LCD",
"SHADOWMASK_BAYER",
"IMAGE_PHASEINFO",
"IMAGE_IN",
"IMAGE_DECODED",
"IMAGE_PERSISTENCE",
]
Insert cell
BUFFER_COUNT = 3
Insert cell
SHADER_NAMES = [
"COMPOSITE",
"DISPLAY",
"RGB",
]
Insert cell
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;
}
}
Insert cell
// Code from:
// https://webglfundamentals.org/webgl/lessons/webgl-fundamentals.html
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}`);
}
Insert cell
// Code from:
// https://webglfundamentals.org/webgl/lessons/webgl-fundamentals.html
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}`);
}
Insert cell
TextureInfo = class {
constructor(width, height, glTexture) {
this.width = width;
this.height = height;
this.glTexture = glTexture;
}

get size() {
return new Size(this.width, this.height);
}
}
Insert cell
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;
}
}
Insert cell
// Corresponds to OEImage. Contains the data on an NTSC/PAL/whatever
// image. The `data` field is an ImageData object with the actual
// image data.
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);
}
}
Insert cell
// https://codereview.stackexchange.com/a/128619
loadImage = path => TEXTURE_IMAGES[path]
Insert cell
TEXTURE_IMAGES = ({
"Shadow Mask Triad.png": FileAttachment("Shadow Mask Triad.png").image(),
"Shadow Mask Inline.png": FileAttachment("Shadow Mask Inline.png").image(),
"Shadow Mask Aperture.png": FileAttachment("Shadow Mask Aperture.png").image(),
"Shadow Mask LCD.png": FileAttachment("Shadow Mask LCD.png").image(),
"Shadow Mask Bayer.png": FileAttachment("Shadow Mask Bayer.png").image(),
"rescue-raiders-words.png": FileAttachment("rescue-raiders-words.png").image(),
})
Insert cell
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("Shadow Mask Triad.png", true, "SHADOWMASK_TRIAD"),
this.loadTexture("Shadow Mask Inline.png", true, "SHADOWMASK_INLINE"),
this.loadTexture("Shadow Mask Aperture.png", true, "SHADOWMASK_APERTURE"),
this.loadTexture("Shadow Mask LCD.png", true, "SHADOWMASK_LCD"),
this.loadTexture("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 = new Size(
this.viewportSize.width,
this.viewportSize.height
);

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
);
}
}
}
Insert cell
{
const canvas = DOM.canvas(768, 576);
let sv = new ScreenView(canvas);
await sv.initOpenGL();

let displayConfig = new DisplayConfiguration();
displayConfig.displayResolution = new Size(canvas.width, canvas.height);
sv.displayConfiguration = displayConfig;
let image = await FileAttachment("rescue-raiders-words.png").image();
const [imageCanvas, imageData] = screenData(image,
NTSC_DETAILS,
false);
let imageInfo = new ImageInfo(imageData);
sv.image = imageInfo;
// Uncomment this to completely hose Firefox, completely lock up the tab in safari,
// sv.vsync();
return canvas;
}
Insert cell
Type JavaScript, then Shift-Enter. Ctrl-space for more options. Arrow ↑/↓ to switch modes.

Insert cell

One platform to build and deploy the best data apps

Experiment and prototype by building visualizations in live JavaScript notebooks. Collaborate with your team and decide which concepts to build out.
Use Observable Framework to build data apps locally. Use data loaders to build in any language or library, including Python, SQL, and R.
Seamlessly deploy to Observable. Test before you ship, use automatic deploy-on-commit, and ensure your projects are always up-to-date.
Learn more