diff --git a/README.md b/README.md index 4f06d40..318863f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,5 @@ # apple2shader -Port(s) of the OpenEmulator NTSC/PAL GPU shader +Port(s) of the OpenEmulator NTSC/PAL GPU shader. + +This is *very much* a work in progress. Right now I'm putting it on +github simply for backup. diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000..626f862 Binary files /dev/null and b/favicon.ico differ diff --git a/images/airheart-560x192.png b/images/airheart-560x192.png new file mode 100644 index 0000000..d246bfb Binary files /dev/null and b/images/airheart-560x192.png differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..c4e4d04 --- /dev/null +++ b/index.html @@ -0,0 +1,280 @@ + + + + + MDN Games: Shaders demo + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Decoder + +
Brightness
Contrast
Saturation
Hue
White Only
Horizontal Center
Horizontal Size
Vertical Center
Vertical Size
Luma Bandwidth
Chroma Bandwidth
B/W Bandwidth
Barrel
Scanline Level
Shadow Mask Level
Shadow Mask Dot Pitch
Shadow Mask + +
Persistence
Center Lighting
Luminance Gain
+
+
+ + +
+
+ + + diff --git a/screenEmu.js b/screenEmu.js new file mode 100644 index 0000000..6cad81a --- /dev/null +++ b/screenEmu.js @@ -0,0 +1,405 @@ +"use strict"; + +let screenEmu = (function () { + // From AppleIIVideo.cpp + + let HORIZ_START = 16; + let HORIZ_BLANK = (9 + HORIZ_START) // 25; + let HORIZ_DISPLAY = 40; + let HORIZ_TOTAL = (HORIZ_BLANK + HORIZ_DISPLAY) // 65; + + let CELL_WIDTH = 14; + let CELL_HEIGHT = 8; + + let VERT_NTSC_START = 38; + let VERT_PAL_START = 48; + let VERT_DISPLAY = 192; + + let BLOCK_WIDTH = HORIZ_DISPLAY; // 40 + let BLOCK_HEIGHT = (VERT_DISPLAY / CELL_HEIGHT); // 24 + + // From CanvasInterface.h + + let NTSC_FSC = 315/88 * 1e6; // 3579545 = 3.5 Mhz: Color Subcarrier + let NTSC_4FSC = 4 * NTSC_FSC; // 14318180 = 14.3 Mhz + let NTSC_HTOTAL = (63+5/9) * 1e-6; + let NTSC_HLENGTH = (52+8/9) * 1e-6; + let NTSC_HHALF = (35+2/3) * 1e-6; + let NTSC_HSTART = NTSC_HHALF - NTSC_HLENGTH/2; + let NTSC_HEND = NTSC_HHALF + NTSC_HLENGTH/2; + let NTSC_VTOTAL = 262; + let NTSC_VLENGTH = 240; + let NTSC_VSTART = 19; + let NTSC_VEND = NTSC_VSTART + NTSC_VLENGTH; + + let PAL_FSC = 4433618.75; // Color subcarrier + let PAL_4FSC = 4 * PAL_FSC; + let PAL_HTOTAL = 64e-6; + let PAL_HLENGTH = 52e-6; + let PAL_HHALF = (37+10/27) * 1e-6; + let PAL_HSTART = PAL_HHALF - PAL_HLENGTH / 2; + let PAL_HEND = PAL_HHALF + PAL_HLENGTH / 2; + let PAL_VTOTAL = 312; + let PAL_VLENGTH = 288; + let PAL_VSTART = 21; + let PAL_VEND = PAL_VSTART + PAL_VLENGTH; + + // From AppleIIVideo::updateTiming + let ntscClockFrequency = NTSC_4FSC * HORIZ_TOTAL / 912; + let ntscVisibleRect = [[ntscClockFrequency * NTSC_HSTART, NTSC_VSTART], + [ntscClockFrequency * NTSC_HLENGTH, NTSC_VLENGTH]]; + let ntscDisplayRect = [[HORIZ_START, VERT_NTSC_START], + [HORIZ_DISPLAY, VERT_DISPLAY]]; + let ntscVertTotal = NTSC_VTOTAL; + + let palClockFrequency = 14250450.0 * HORIZ_TOTAL / 912; + let palVisibleRect = [[palClockFrequency * PAL_HSTART, PAL_VSTART], + [palClockFrequency * PAL_HLENGTH, PAL_VLENGTH]]; + let palDisplayRect = [[HORIZ_START, VERT_PAL_START], + [HORIZ_DISPLAY, VERT_DISPLAY]]; + let palVertTotal = PAL_VTOTAL; + + const COMPOSITE_SHADER = ` +uniform sampler2D texture; +uniform vec2 textureSize; +uniform float subcarrier; +uniform sampler1D 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 = texture1D(phaseInfo, 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 = gl_TexCoord[0].st; + 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 = ` +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 = (gl_TexCoord[1].st - vec2(0.5, 0.5)) * barrelSize; + vec2 qb = barrel * qc * dot(qc, qc); + vec2 q = gl_TexCoord[0].st + 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, (gl_TexCoord[1].st + 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 = gl_TexCoord[1].st * persistenceSize + persistenceOrigin; + c = max(c, texture2D(persistence, qp).rgb * persistenceLevel - 0.5 / 256.0); + + gl_FragColor = vec4(c, 1.0); +} +`; + + function buildTiming(clockFrequency, displayRect, visibleRect, vertTotal) { + let vertStart = displayRect[0][1]; + // Total number of CPU cycles per frame: 17030 for NTSC. + let frameCycleNum = HORIZ_TOTAL * vertTotal; + // first displayed column. + let horizStart = Math.floor(displayRect[0][0]); + // imageSize is [14 * visible rect width in cells, visible lines] + let imageSize = [Math.floor(CELL_WIDTH * visibleRect[1][0]), + Math.floor(visibleRect[1][1])]; + // imageLeft is # of pixels from first visible point to first displayed point. + let imageLeft = Math.floor((horizStart-visibleRect[0][0]) * CELL_WIDTH); + let colorBurst = [2 * Math.PI * (-33/360 + (imageLeft % 4) / 4)]; + let cycleNum = frameCycleNum + 16; + + // First pixel that OpenEmulator draws when painting normally. + let topLeft = [imageLeft, vertStart - visibleRect[0][1]]; + // First pixel that OpenEmulator draws when painting 80-column mode. + let topLeft80Col = [imageLeft - CELL_WIDTH/2, vertStart - visibleRect[0][1]]; + + return { + 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, + }; + } + + // 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) => { + if ((image.naturalWidth != 560) || (image.naturalHeight != 192)) { + throw `screenData expects an image 560x192; got ${image.naturalWidth}x${image.naturalHeight}`; + } + let canvas = document.createElement('canvas'); + let context = canvas.getContext('2d'); + let width = details.imageSize[0]; + let height = details.imageSize[1]; + canvas.width = width; + canvas.height = height; + context.fillStyle = 'rgba(0,0,0,1)'; + context.fillRect(0, 0, width, height); + context.drawImage(image, details.topLeft80Col[0], details.topLeft80Col[1]); + // let myData = context.getImageData(0, 0, image.naturalWidth, image.naturalHeight); + return canvas; + }; + + const TEXTURE_NAMES = [ + "SHADOWMASK_TRIAD", + "SHADOWMASK_INLINE", + "SHADOWMASK_APERTURE", + "SHADOWMASK_LCD", + "SHADOWMASK_BAYER", + "IMAGE_PHASEINFO", + "IMAGE_IN", + "IMAGE_DECODED", + "IMAGE_PERSISTENCE", + ]; + + const SHADER_NAMES = [ + "COMPOSITE", + "DISPLAY", + ]; + + const TextureInfo = class { + constructor(width, height, glTexture) { + this.width = width; + this.height = height; + this.glTexture = glTexture; + } + }; + + const ScreenView = class { + constructor(gl) { + this.gl = gl; + this.textures = {}; + this.shaders = {}; + } + + async initOpenGL() { + let 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()); + } + + await this.loadTextures(); + + gl.pixelStorei(gl.PACK_ALIGNMENT, 1); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + + this.loadShaders(); + } + + freeOpenGL() { + let gl = this.gl; + + for (let name of TEXTURE_NAMES) { + gl.deleteTexture(this.textures[name].glTexture); + } + + 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) { + let gl = this.gl; + let textureInfo = this.textures[name]; + let image = await loadImage(path); + gl.bindTexture(gl.TEXTURE_2D, textureInfo.glTexture); + + // TODO(zellyn): implement + if (isMipMap) { + // gluBuild2DMipmaps(GL_TEXTURE_2D, GL_RGB8, + // image.getSize().width, image.getSize().height, + // getGLFormat(image.getFormat()), + // GL_UNSIGNED_BYTE, image.getPixels()); + } else { + // glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, + // image.getSize().width, image.getSize().height, + // 0, + // getGLFormat(image.getFormat()), GL_UNSIGNED_BYTE, image.getPixels()); + } + + textureInfo.width = image.naturalWidth; + textureInfo.height = image.naturalHeight; + } + + // TODO(zellyn): implement + loadShaders() { + this.loadShader("COMPOSITE", COMPOSITE_SHADER); + this.loadShader("DISPLAY", DISPLAY_SHADER); + } + + // TODO(zellyn): implement + loadShader(name, source) { + console.log(`ScreenView.loadShader(${name}): not implemented yet`); + } + + // TODO(zellyn): implement + deleteShaders() { + for (let name of SHADER_NAMES) { + if (this.shaders[name]) { + gl.deleteProgram(this.shaders[name]); + this.shaders[name] = false; + } + } + } + } + + // Resize the texture with the given name to the next + // highest power of two width and height. Wouldn't be + // necessary with webgl2. + const resizeTexture = (gl, textures, name, width, height) => { + let textureInfo = textures[name]; + if (!!textureInfo) { + throw `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)); + textureInfo.width = width; + textureInfo.height = height; + gl.bindTexture(gl.TEXTURE_2D, textureInfo.glTexture); + const dummy = new Uint8Array(width * height); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, width, height, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, dummy); + }; + + const vsync = (gl) => { + // if viewport size has changed: + // glViewPort(0, 0, new_width, new_height); + + // if image updated: + // uploadImage(); + + // if configuration updated: + // configureShaders(); + + // if image or configuration updated: + // renderImage(); + + // if anything updated, or displayPersistence != 0.0 + // drawDisplayCanvas(); + }; + + // TODO(zellyn): implement + const uploadImage = (gl) => { + }; + + // TODO(zellyn): implement + const configureShaders = (gl) => { + }; + + // TODO(zellyn): implement + const renderImage = (gl) => { + }; + + // TODO(zellyn): implement + const drawDisplayCanvas = (gl) => { + }; + + 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_DETAILS: buildTiming(ntscClockFrequency, ntscDisplayRect, + ntscVisibleRect, ntscVertTotal), + PAL_DETAILS: buildTiming(palClockFrequency, palDisplayRect, + palVisibleRect, palVertTotal), + }, + loadImage: loadImage, + screenData: screenData, + resizeTexture: resizeTexture, + getScreenView: (gl) => new ScreenView(gl), + }; +})(); diff --git a/textures/Shadow Mask Aperture.png b/textures/Shadow Mask Aperture.png new file mode 100644 index 0000000..4f3fb2b Binary files /dev/null and b/textures/Shadow Mask Aperture.png differ diff --git a/textures/Shadow Mask Bayer.png b/textures/Shadow Mask Bayer.png new file mode 100644 index 0000000..dfc7839 Binary files /dev/null and b/textures/Shadow Mask Bayer.png differ diff --git a/textures/Shadow Mask Inline.png b/textures/Shadow Mask Inline.png new file mode 100644 index 0000000..4b5a5c8 Binary files /dev/null and b/textures/Shadow Mask Inline.png differ diff --git a/textures/Shadow Mask LCD.png b/textures/Shadow Mask LCD.png new file mode 100644 index 0000000..1384186 Binary files /dev/null and b/textures/Shadow Mask LCD.png differ diff --git a/textures/Shadow Mask Triad.png b/textures/Shadow Mask Triad.png new file mode 100644 index 0000000..7e03b10 Binary files /dev/null and b/textures/Shadow Mask Triad.png differ