jsbeeb 1.15.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Compile and link a shader program, throwing with the driver's own message if
5
+ * either step fails.
6
+ *
7
+ * @param {WebGLRenderingContext} gl
8
+ * @param {string} vertexSource
9
+ * @param {string} fragmentSource
10
+ * @param {string} name used in error messages, e.g. "xBR"
11
+ * @returns {WebGLProgram}
12
+ */
13
+ export function compileProgram(gl, vertexSource, fragmentSource, name) {
14
+ const vertexShader = compileShader(gl, gl.VERTEX_SHADER, vertexSource, name);
15
+ let fragmentShader = null;
16
+ try {
17
+ fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource, name);
18
+
19
+ const program = gl.createProgram();
20
+ gl.attachShader(program, vertexShader);
21
+ gl.attachShader(program, fragmentShader);
22
+ gl.linkProgram(program);
23
+
24
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
25
+ const info = gl.getProgramInfoLog(program);
26
+ gl.deleteProgram(program);
27
+ throw new Error(`Failed to link ${name} shader program: ${info}`);
28
+ }
29
+
30
+ return program;
31
+ } finally {
32
+ // Once linked the program holds its own reference, so releasing ours here means
33
+ // deleting the program later frees the shaders too. If we never got that far, ours
34
+ // was the only reference and they go now.
35
+ gl.deleteShader(vertexShader);
36
+ gl.deleteShader(fragmentShader);
37
+ }
38
+ }
39
+
40
+ function compileShader(gl, type, source, name) {
41
+ const shader = gl.createShader(type);
42
+ gl.shaderSource(shader, source);
43
+ gl.compileShader(shader);
44
+
45
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
46
+ const info = gl.getShaderInfoLog(shader);
47
+ const typeName = type === gl.VERTEX_SHADER ? "vertex" : "fragment";
48
+ gl.deleteShader(shader);
49
+ throw new Error(`Failed to compile ${name} ${typeName} shader: ${info}`);
50
+ }
51
+
52
+ return shader;
53
+ }
@@ -0,0 +1,278 @@
1
+ // xBR-lv2 edge-directed upscaling, applied to the BBC's *logical* pixels.
2
+ //
3
+ // Ported from Hyllian's xbr-lv2-standalone.slang (MIT, Copyright (C) 2011-2022
4
+ // Hyllian <sergiogdb@gmail.com>); see that for the original. tests/shader runs
5
+ // this file in headless Chrome and asserts on the pixels it produces.
6
+ //
7
+ // The one thing this does that a stock xBR shader does not: jsbeeb's
8
+ // framebuffer is a 1024-wide raster in which one BBC pixel spans up to eight
9
+ // texels horizontally and two vertically. Sampling raw texel neighbours would
10
+ // find nine copies of the same pixel and do nothing at all, so every sample
11
+ // here is taken on the logical grid, whose size for each row comes from the
12
+ // lineGrid texture that Video fills in (see video-filters/pixel-grid.js).
13
+
14
+ // Texel coordinates run to 1024 and are floored to find the pixel grid, which
15
+ // mediump's ~10 bits of mantissa cannot resolve — the grid would land on the
16
+ // wrong texel and the picture would break up. XbrFilter refuses to build on
17
+ // hardware without high precision, so this shader never runs at mediump; the
18
+ // fallback is here only so it still compiles if something else includes it.
19
+ #ifdef GL_FRAGMENT_PRECISION_HIGH
20
+ precision highp float;
21
+ #else
22
+ precision mediump float;
23
+ #endif
24
+
25
+ uniform sampler2D tex; // the framebuffer
26
+ uniform sampler2D lineGrid; // one descriptor byte per framebuffer row
27
+ uniform vec2 uTextureSize; // framebuffer texture size, in texels
28
+ uniform vec2 uTexelSize; // 1.0 / uTextureSize
29
+ uniform float uTexelsPerOutputPixel; // horizontal, for the antialiasing ramp
30
+
31
+ varying vec2 uv;
32
+
33
+ // Colour distance below which two pixels count as "equal".
34
+ const float EqThreshold = 0.32;
35
+ // Used as the threshold of a step(), so larger values make the shallow 30/60
36
+ // degree rules harder to satisfy and fewer shallow edges get smoothed.
37
+ const float Lv2Coefficient = 0.3;
38
+ const float Lv2Cf = Lv2Coefficient + 2.0;
39
+
40
+ // Coefficients of the straight lines that bound each interpolation region.
41
+ const vec4 Ao = vec4(1.0, -1.0, -1.0, 1.0);
42
+ const vec4 Bo = vec4(1.0, 1.0, -1.0, -1.0);
43
+ const vec4 Co = vec4(1.5, 0.5, -0.5, 0.5);
44
+ const vec4 Ax = vec4(1.0, -1.0, -1.0, 1.0);
45
+ const vec4 Bx = vec4(0.5, 2.0, -0.5, -2.0);
46
+ const vec4 Cx = vec4(1.0, 1.0, -0.5, 0.0);
47
+ const vec4 Ay = vec4(1.0, -1.0, -1.0, 1.0);
48
+ const vec4 By = vec4(2.0, 0.5, -2.0, -0.5);
49
+ const vec4 Cy = vec4(2.0, 0.0, -1.0, 0.5);
50
+ const vec4 Ci = vec4(0.25, 0.25, 0.25, 0.25);
51
+
52
+ // BT.2020 luma weights, used to weight the per-channel colour distance.
53
+ const vec3 Y = vec3(0.2627, 0.678, 0.0593);
54
+
55
+ // Line grid descriptor bits; see video-filters/pixel-grid.js.
56
+ const float GridRendered = 128.0;
57
+ const float GridVerticalDouble = 8.0;
58
+
59
+ // Four colours held channel-wise, standing in for the reference shader's
60
+ // mat4x3. The four lanes are the same rule applied to the four rotations of
61
+ // the neighbourhood, so nearly every term is just a relabelling.
62
+ struct Lane4 {
63
+ vec4 r;
64
+ vec4 g;
65
+ vec4 b;
66
+ };
67
+
68
+ Lane4 lane(vec3 x, vec3 y, vec3 z, vec3 w) {
69
+ return Lane4(vec4(x.r, y.r, z.r, w.r), vec4(x.g, y.g, z.g, w.g), vec4(x.b, y.b, z.b, w.b));
70
+ }
71
+
72
+ /** Perceptually weighted colour distance, per lane. */
73
+ vec4 dist4(Lane4 a, Lane4 b) {
74
+ return abs(a.r - b.r) * Y.r + abs(a.g - b.g) * Y.g + abs(a.b - b.b) * Y.b;
75
+ }
76
+
77
+ vec4 eq4(Lane4 a, Lane4 b) {
78
+ return step(dist4(a, b), vec4(EqThreshold));
79
+ }
80
+
81
+ vec4 neq4(Lane4 a, Lane4 b) {
82
+ return vec4(1.0) - eq4(a, b);
83
+ }
84
+
85
+ /** Distance among pixels in some direction; the 4x term dominates. */
86
+ vec4 weightedDistance(Lane4 a, Lane4 b, Lane4 c, Lane4 d, Lane4 e, Lane4 f, Lane4 g, Lane4 h) {
87
+ return dist4(a, b) + dist4(a, c) + dist4(d, e) + dist4(d, f) + 4.0 * dist4(g, h);
88
+ }
89
+
90
+ /**
91
+ * Pack a colour so exact equality is one comparison rather than three, matching
92
+ * the reference shader's `v2f` trick.
93
+ */
94
+ vec4 pack4(Lane4 a) {
95
+ return a.r * 65536.0 + a.g * 256.0 + a.b;
96
+ }
97
+
98
+ float pack(vec3 c) {
99
+ return c.r * 65536.0 + c.g * 256.0 + c.b;
100
+ }
101
+
102
+ /** Exact (not thresholded) inequality. */
103
+ vec4 diff4(vec4 a, vec4 b) {
104
+ return vec4(notEqual(a, b));
105
+ }
106
+
107
+ float colourDistance(vec3 a, vec3 b) {
108
+ return dot(abs(a - b), Y);
109
+ }
110
+
111
+ /** Pick whichever candidate is further from the centre pixel. */
112
+ vec3 furtherFrom(vec3 e, vec3 res1, vec3 res2) {
113
+ return mix(res1, res2, step(colourDistance(e, res1), colourDistance(e, res2)));
114
+ }
115
+
116
+ void main() {
117
+ // Absolute texel coordinates within the framebuffer.
118
+ vec2 fbCoord = uv * uTextureSize;
119
+
120
+ // This row's logical pixel size. The descriptor is a byte, so recover it by
121
+ // scaling and rounding rather than trusting the sampled float directly.
122
+ float row = floor(fbCoord.y);
123
+ float descriptor = floor(texture2D(lineGrid, vec2((row + 0.5) * uTexelSize.x, 0.5)).r * 255.0 + 0.5);
124
+ descriptor -= step(GridRendered, descriptor) * GridRendered;
125
+ float verticalDouble = step(GridVerticalDouble, descriptor);
126
+ descriptor -= verticalDouble * GridVerticalDouble;
127
+ // What is left is the pixel's width in texels, less one.
128
+ vec2 pixelSize = vec2(descriptor + 1.0, 1.0 + verticalDouble);
129
+
130
+ // Everything below works in logical pixels, as a stock xBR shader would.
131
+ vec2 logical = fbCoord / pixelSize;
132
+ vec2 centre = floor(logical);
133
+ vec2 fp = logical - centre;
134
+
135
+ // Sample the centre of the first texel of each logical pixel. Every texel
136
+ // within a logical pixel holds the same colour — the ULA writes them from
137
+ // one table entry — so which one is arbitrary; the first avoids landing on
138
+ // a boundary and rounding the wrong way.
139
+ vec2 base = centre * pixelSize + 0.5;
140
+ #define AT(dx, dy) texture2D(tex, (base + vec2(dx, dy) * pixelSize) * uTexelSize).rgb
141
+
142
+ // A1 B1 C1
143
+ // A0 A B C C4
144
+ // D0 D E F F4
145
+ // G0 G H I I4
146
+ // G5 H5 I5
147
+ vec3 E = AT(0.0, 0.0);
148
+ vec3 B = AT(0.0, -1.0);
149
+ vec3 D = AT(-1.0, 0.0);
150
+ vec3 F = AT(1.0, 0.0);
151
+ vec3 H = AT(0.0, 1.0);
152
+
153
+ // Level 0: the centre differs from both its right and lower neighbours,
154
+ // i.e. there is an edge here at all. Every rule below is gated on it, so
155
+ // where it is zero in all four rotations the answer is exactly E — and
156
+ // most of a BBC screen is flat colour. Taking that exit early skips
157
+ // sixteen texture fetches and the whole of the algorithm.
158
+ //
159
+ // Correctness rests on this being the same irlv0 the full path computes.
160
+ // tests/shader asserts what the shader is for — flat areas untouched, hard
161
+ // edges left hard, diagonals smoothed — so a shortcut that took this exit
162
+ // where the algorithm would have blended shows up there.
163
+ //
164
+ // Returning here also puts every texture2D below inside non-uniform
165
+ // control flow, which GLSL ES 1.0 section 8.7 leaves undefined for lookups
166
+ // needing implicit derivatives. It is defined here only because there is
167
+ // no level of detail to choose: XbrFilter asks for `nearestSampling`, so
168
+ // canvas.js gives this texture NEAREST for both filters, and it has no
169
+ // mipmaps. Move this filter back to LINEAR, or give the texture mipmaps,
170
+ // and this shortcut stops being merely faster and starts being undefined.
171
+ vec4 eP = vec4(pack(E));
172
+ vec4 fP = vec4(pack(F), pack(B), pack(D), pack(H));
173
+ vec4 hP = fP.wxyz;
174
+ vec4 irlv0 = vec4(notEqual(eP, fP)) * vec4(notEqual(eP, hP));
175
+ if (all(equal(irlv0, vec4(0.0)))) {
176
+ gl_FragColor = vec4(E, 1.0);
177
+ return;
178
+ }
179
+
180
+ vec3 A1 = AT(-1.0, -2.0);
181
+ vec3 B1 = AT(0.0, -2.0);
182
+ vec3 C1 = AT(1.0, -2.0);
183
+ vec3 A = AT(-1.0, -1.0);
184
+ vec3 C = AT(1.0, -1.0);
185
+ vec3 G = AT(-1.0, 1.0);
186
+ vec3 I = AT(1.0, 1.0);
187
+ vec3 G5 = AT(-1.0, 2.0);
188
+ vec3 H5 = AT(0.0, 2.0);
189
+ vec3 I5 = AT(1.0, 2.0);
190
+ vec3 A0 = AT(-2.0, -1.0);
191
+ vec3 D0 = AT(-2.0, 0.0);
192
+ vec3 G0 = AT(-2.0, 1.0);
193
+ vec3 C4 = AT(2.0, -1.0);
194
+ vec3 F4 = AT(2.0, 0.0);
195
+ vec3 I4 = AT(2.0, 1.0);
196
+ #undef AT
197
+
198
+ Lane4 b = lane(B, D, H, F);
199
+ Lane4 c = lane(C, A, G, I);
200
+ Lane4 d = lane(D, H, F, B);
201
+ Lane4 e = lane(E, E, E, E);
202
+ Lane4 f = lane(F, B, D, H);
203
+ Lane4 g = lane(G, I, C, A);
204
+ Lane4 h = lane(H, F, B, D);
205
+ Lane4 i = lane(I, C, A, G);
206
+ Lane4 i4 = lane(I4, C1, A0, G5);
207
+ Lane4 i5 = lane(I5, C4, A1, G0);
208
+ Lane4 h5 = lane(H5, F4, B1, D0);
209
+ Lane4 f4 = lane(F4, B1, D0, H5);
210
+
211
+ // Packed forms, for the exact-inequality tests. `fP` and `hP` are already in
212
+ // hand from the early-out above, and `b` is `f` rotated, so `bP` is too —
213
+ // packing it again would cost eight multiplies for the same bits.
214
+ vec4 bP = fP.yzwx;
215
+ vec4 cP = pack4(c);
216
+ vec4 dP = bP.yzwx;
217
+ vec4 gP = cP.zwxy;
218
+
219
+ // These inequations define the line below which interpolation occurs.
220
+ vec4 fx = Ao * fp.y + Bo * fp.x;
221
+ vec4 fxL = Ax * fp.y + Bx * fp.x;
222
+ vec4 fxU = Ay * fp.y + By * fp.x;
223
+
224
+ // Corner detection variant C: also require the edge to be part of a longer
225
+ // run, so isolated single pixels are not rounded away.
226
+ vec4 eqEC = eq4(e, c);
227
+ vec4 eqEG = eq4(e, g);
228
+ vec4 irlv1 = clamp(
229
+ irlv0 *
230
+ (neq4(f, b) * neq4(f, c) + neq4(h, d) * neq4(h, g) +
231
+ eq4(e, i) * (neq4(f, f4) * neq4(f, i4) + neq4(h, h5) * neq4(h, i5)) + eqEG + eqEC),
232
+ 0.0,
233
+ 1.0);
234
+
235
+ // Level 2 restrictions gate the shallower 30 and 60 degree edges.
236
+ vec4 irlv2l = diff4(eP, gP) * diff4(dP, gP);
237
+ vec4 irlv2u = diff4(eP, cP) * diff4(bP, cP);
238
+
239
+ // How far across the blend ramp this fragment sits, for each edge angle.
240
+ // Two output pixels' worth of source, matching the reference's aa_factor.
241
+ float aaFactor = 2.0 * uTexelsPerOutputPixel / pixelSize.x;
242
+ vec4 delta = vec4(aaFactor);
243
+ vec4 deltaL = vec4(0.5, 1.0, 0.5, 1.0) * aaFactor;
244
+ vec4 deltaU = deltaL.yxwz;
245
+
246
+ vec4 fx45i = clamp(0.5 + (fx - Co - Ci) / delta, 0.0, 1.0);
247
+ vec4 fx45 = clamp(0.5 + (fx - Co) / delta, 0.0, 1.0);
248
+ vec4 fx30 = clamp(0.5 + (fxL - Cx) / deltaL, 0.0, 1.0);
249
+ vec4 fx60 = clamp(0.5 + (fxU - Cy) / deltaU, 0.0, 1.0);
250
+
251
+ // Which way does the edge run? wd1 small means the main diagonal through E,
252
+ // wd2 small means the anti-diagonal.
253
+ vec4 wd1 = weightedDistance(e, c, g, i, h5, f4, h, f);
254
+ vec4 wd2 = weightedDistance(h, d, i5, f, i4, b, e, i);
255
+
256
+ vec4 dFG = dist4(f, g);
257
+ vec4 dHC = dist4(h, c);
258
+
259
+ vec4 edri = step(wd1, wd2) * irlv0;
260
+ vec4 edr = vec4(lessThan(wd1, wd2)) * irlv1 * (vec4(1.0) - edri.yzwx * edri.wxyz);
261
+ vec4 edrL = step(Lv2Cf * dFG, dHC) * irlv2l * edr * ((vec4(1.0) - edri.yzwx) * eqEC);
262
+ vec4 edrU = step(Lv2Cf * dHC, dFG) * irlv2u * edr * ((vec4(1.0) - edri.wxyz) * eqEG);
263
+
264
+ fx45i *= edri;
265
+ fx45 *= edr;
266
+ fx30 *= edrL;
267
+ fx60 *= edrU;
268
+
269
+ // Of the two pixels either side of the edge, blend towards the nearer one.
270
+ vec4 px = step(dist4(e, f), dist4(e, h));
271
+
272
+ vec4 maximos = max(max(fx30, fx60), max(fx45, fx45i));
273
+
274
+ vec3 resA = furtherFrom(E, mix(E, mix(H, F, px.x), maximos.x), mix(E, mix(B, D, px.z), maximos.z));
275
+ vec3 resB = furtherFrom(E, mix(E, mix(F, B, px.y), maximos.y), mix(E, mix(D, H, px.w), maximos.w));
276
+
277
+ gl_FragColor = vec4(furtherFrom(E, resA, resB), 1.0);
278
+ }
@@ -0,0 +1,7 @@
1
+ attribute vec2 pos;
2
+ attribute vec2 uvIn;
3
+ varying vec2 uv;
4
+ void main() {
5
+ uv = uvIn;
6
+ gl_Position = vec4(2.0 * pos - 1.0, 0.0, 1.0);
7
+ }
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+
3
+ // The xBR-lv2 display mode. See shaders/xbr.frag.glsl for the algorithm, and
4
+ // tests/shader, which runs it in a browser and asserts on what it draws.
5
+
6
+ import VERT_SHADER from "./shaders/xbr.vert.glsl?raw";
7
+ import FRAG_SHADER from "./shaders/xbr.frag.glsl?raw";
8
+ import { compileProgram } from "./shader-program.js";
9
+ import { LineGridRows } from "./pixel-grid.js";
10
+
11
+ // The framebuffer texture GlCanvas hands us is square and fixed at this size.
12
+ const TextureSize = 1024;
13
+
14
+ export class XbrFilter {
15
+ static getDisplayConfig() {
16
+ return {
17
+ name: "Smoothed (xBR)",
18
+ image: "images/cub-monitor.png",
19
+ imageAlt: "A fake CUB computer monitor",
20
+ imageWidth: 896,
21
+ imageHeight: 648,
22
+ canvasLeft: 0,
23
+ canvasTop: 8,
24
+ visibleWidth: 896,
25
+ visibleHeight: 600,
26
+ canvasWidth: 896,
27
+ canvasHeight: 600,
28
+ // Reconstructed detail needs somewhere to go, so this mode will
29
+ // draw into up to twice the usual canvas — but only as far as the
30
+ // display can actually show. Rendering 1792x1200 into a window
31
+ // showing 900 pixels costs four times the fragments for nothing,
32
+ // and this shader is expensive per fragment.
33
+ maxCanvasScale: 2,
34
+ // The shader picks its own samples on the logical pixel grid, so
35
+ // hardware interpolation would only blur what it reads. It is also
36
+ // what makes the shader's early return legal: with no LOD to
37
+ // choose, its texture lookups need no implicit derivatives. See
38
+ // the note beside that return in xbr.frag.glsl before changing it.
39
+ nearestSampling: true,
40
+ };
41
+ }
42
+
43
+ constructor(gl) {
44
+ this.gl = gl;
45
+
46
+ // The shader floors framebuffer coordinates that run to 1024 to find
47
+ // the pixel grid. mediump cannot resolve one texel from the next up
48
+ // there, so the grid would land on the wrong texel and the picture
49
+ // would come apart. Better to fall back to an unfiltered display than
50
+ // to render a broken one.
51
+ const highp = gl.getShaderPrecisionFormat(gl.FRAGMENT_SHADER, gl.HIGH_FLOAT);
52
+ if (!highp || highp.precision === 0)
53
+ throw new Error("xBR needs high fragment shader precision, which this device does not offer");
54
+
55
+ this.program = compileProgram(gl, VERT_SHADER, FRAG_SHADER, "xBR");
56
+ this.locations = {
57
+ tex: gl.getUniformLocation(this.program, "tex"),
58
+ lineGrid: gl.getUniformLocation(this.program, "lineGrid"),
59
+ textureSize: gl.getUniformLocation(this.program, "uTextureSize"),
60
+ texelSize: gl.getUniformLocation(this.program, "uTexelSize"),
61
+ texelsPerOutputPixel: gl.getUniformLocation(this.program, "uTexelsPerOutputPixel"),
62
+ };
63
+
64
+ // One row holding the logical pixel size of each framebuffer row, laid
65
+ // out along x rather than y: 1024 contiguous bytes upload as a single
66
+ // row, where a one-texel-wide column would be 1024 rows of one byte and
67
+ // would need the unpack alignment changed around every upload.
68
+ this.lineGridTexture = gl.createTexture();
69
+ gl.activeTexture(gl.TEXTURE1);
70
+ gl.bindTexture(gl.TEXTURE_2D, this.lineGridTexture);
71
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
72
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
73
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
74
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
75
+ // Its size is fixed, so allocate now and only ever refill it.
76
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE, LineGridRows, 1, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, null);
77
+ gl.activeTexture(gl.TEXTURE0);
78
+
79
+ // Everything but the line grid and the output scale is fixed for the
80
+ // life of the program, so set it once. GlCanvas has not called
81
+ // useProgram yet, and does so again straight after building us.
82
+ gl.useProgram(this.program);
83
+ gl.uniform1i(this.locations.tex, 0);
84
+ gl.uniform1i(this.locations.lineGrid, 1);
85
+ gl.uniform2f(this.locations.textureSize, TextureSize, TextureSize);
86
+ gl.uniform2f(this.locations.texelSize, 1.0 / TextureSize, 1.0 / TextureSize);
87
+ }
88
+
89
+ /** Release the GL objects this filter owns, the extra texture included. */
90
+ dispose() {
91
+ this.gl.deleteProgram(this.program);
92
+ this.gl.deleteTexture(this.lineGridTexture);
93
+ this.program = this.lineGridTexture = null;
94
+ }
95
+
96
+ setUniforms(params) {
97
+ const gl = this.gl;
98
+ gl.uniform1f(this.locations.texelsPerOutputPixel, params.texelsPerOutputPixel);
99
+
100
+ gl.activeTexture(gl.TEXTURE1);
101
+ gl.bindTexture(gl.TEXTURE_2D, this.lineGridTexture);
102
+ gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, LineGridRows, 1, gl.LUMINANCE, gl.UNSIGNED_BYTE, params.lineGrid);
103
+ // Leave unit 0 active and the framebuffer bound: the canvas uploads the
104
+ // next frame's pixels through whatever is current.
105
+ gl.activeTexture(gl.TEXTURE0);
106
+ }
107
+ }
package/src/video.js CHANGED
@@ -3,6 +3,7 @@ import { Teletext } from "./teletext.js";
3
3
  import * as utils from "./utils.js";
4
4
  import { BbcDefaultPalette as NulaDefaultPalette } from "./bbc-palette.js";
5
5
  import { Video6847 } from "./6847.js";
6
+ import { encodeLineGrid, texelsPerPixel, LineGridRows } from "./video-filters/pixel-grid.js";
6
7
 
7
8
  export const VDISPENABLE = 1 << 0;
8
9
  export const HDISPENABLE = 1 << 1;
@@ -16,6 +17,8 @@ export const EVERYTHINGENABLED =
16
17
  export const OPAQUE_BLACK = 0xff000000;
17
18
  export const OPAQUE_WHITE = 0xffffffff;
18
19
 
20
+ export const MinPaintedFrameRows = 64;
21
+
19
22
  ////////////////////
20
23
  // VideoNULA - programmable 12-bit RGB palette extension (RobC hardware mod).
21
24
  // Reference: b-em src/video.c (stardot/b-em).
@@ -127,6 +130,7 @@ class Ula {
127
130
  this.video.ulaMode = newMode;
128
131
  }
129
132
  this.video.teletextMode = !!(val & 2);
133
+ this.video.updateLineGridUla();
130
134
  }
131
135
 
132
136
  // ULA palette register (&FE21).
@@ -386,6 +390,17 @@ export class Video {
386
390
  this.doubledScanlines = true;
387
391
  this.frameSkipCount = 0;
388
392
  this.screenSubtract = 0;
393
+ // Describes the logical pixel grid of each framebuffer row: how many
394
+ // texels wide and tall one BBC pixel is on that line. Display filters
395
+ // need this to see the picture as pixels rather than as raster samples;
396
+ // see video-filters/pixel-grid.js. One byte per row, written as each
397
+ // character cell renders, so a mode change between rows is recorded
398
+ // faithfully. A row whose mode changes part way along keeps only the
399
+ // last mode on it — enough for a raster split, not for a mid-line one.
400
+ this.lineGrid = new Uint8Array(LineGridRows);
401
+ this.lineGridUla = 0;
402
+ this.lineGridUlaDoubled = 0;
403
+ this.updateLineGridUla();
389
404
 
390
405
  this.topBorder = 12;
391
406
  this.bottomBorder = 13;
@@ -524,6 +539,8 @@ export class Video {
524
539
  this.halfClock = state.halfClock;
525
540
  this.ulaMode = state.ulaMode;
526
541
  this.teletextMode = state.teletextMode;
542
+ // Derived from the above, so it is recomputed rather than snapshotted.
543
+ this.updateLineGridUla();
527
544
  this.displayEnableSkew = state.displayEnableSkew;
528
545
  this.actualPal.set(state.actualPal);
529
546
  this.cursorOn = state.cursorOn;
@@ -555,20 +572,24 @@ export class Video {
555
572
 
556
573
  clearPaintBuffer() {
557
574
  const fb32 = this.fb32;
575
+ // The line grid is cleared exactly where the pixels are: in interlaced
576
+ // modes the other field's rows survive, and so must their grid.
558
577
  if (this.interlacedSyncAndVideo || !this.doubledScanlines) {
559
578
  let line = this.frameCount & 1;
560
579
  while (line < 625) {
561
580
  const start = line * 1024;
562
581
  fb32.fill(OPAQUE_BLACK, start, start + 1024);
582
+ this.lineGrid[line] = 0;
563
583
  line += 2;
564
584
  }
565
585
  } else {
566
586
  fb32.fill(OPAQUE_BLACK);
587
+ this.lineGrid.fill(0);
567
588
  }
568
589
  }
569
590
 
570
- paintAndClear() {
571
- if (this.dispEnabled & FRAMESKIPENABLE) {
591
+ flyback() {
592
+ if (this.bitmapY >= MinPaintedFrameRows && this.dispEnabled & FRAMESKIPENABLE) {
572
593
  this.paint();
573
594
  this.clearPaintBuffer();
574
595
  }
@@ -610,6 +631,21 @@ export class Video {
610
631
  debugCopyFb(this.fb32, this.debugPrevScreen);
611
632
  }
612
633
 
634
+ /**
635
+ * Recompute the ULA-dependent half of the line grid descriptor: everything
636
+ * except whether this particular scanline was doubled. Called whenever the
637
+ * ULA control register changes, so the render loop only has to store it.
638
+ *
639
+ * MODE 7 counts as one texel per pixel: the SAA5050 emulation writes each
640
+ * of its 16 texels per character individually, so its output is already at
641
+ * the framebuffer's own resolution.
642
+ */
643
+ updateLineGridUla() {
644
+ const texelsWide = this.teletextMode ? 1 : texelsPerPixel(this.ulaMode);
645
+ this.lineGridUla = encodeLineGrid(texelsWide, false);
646
+ this.lineGridUlaDoubled = encodeLineGrid(texelsWide, true);
647
+ }
648
+
613
649
  blitFb(dat, destOffset, numPixels) {
614
650
  destOffset |= 0;
615
651
  const offset = table4bppOffset(this.ulaMode, dat);
@@ -816,14 +852,12 @@ export class Video {
816
852
  // an approximation that works if hsyncs are spaced evenly.
817
853
  this.bitmapY += 2;
818
854
 
819
- // If no VSync occurs this frame, go back to the top and force a repaint
820
- if (this.bitmapY >= 768) {
821
- // Arbitrary moment when TV will give up and start flyback in the absence of an explicit VSync signal
822
- this.paintAndClear();
823
- }
855
+ // Arbitrary moment when TV will give up and start flyback in the absence of an explicit VSync signal
856
+ return this.bitmapY >= 768;
824
857
  } else if (this.hpulseCounter === (this.regs[3] & 0x0f)) {
825
858
  this.inHSync = false;
826
859
  }
860
+ return false;
827
861
  }
828
862
 
829
863
  cb2changed(level, output) {
@@ -871,7 +905,7 @@ export class Video {
871
905
  // This emulates the Hitachi 6845SP CRTC.
872
906
  // Other variants have different quirks.
873
907
  // Handle HSync
874
- if (this.inHSync) this.handleHSync();
908
+ if (this.inHSync && this.handleHSync()) this.flyback();
875
909
 
876
910
  // Handle delayed display enable due to skew
877
911
  const displayEnablePos = this.displayEnableSkew + (this.teletextMode ? 2 : 0);
@@ -935,11 +969,7 @@ export class Video {
935
969
  this.hadVSyncThisRow = true;
936
970
  this.vpulseCounter = 0;
937
971
 
938
- // Avoid intense painting if registers have boot-up or
939
- // otherwise small values.
940
- if (this.regs[0] && this.regs[4]) {
941
- this.paintAndClear();
942
- }
972
+ this.flyback();
943
973
  }
944
974
 
945
975
  if (vSyncStarting || vSyncEnding) {
@@ -974,7 +1004,7 @@ export class Video {
974
1004
  // Render data depending on display enable state.
975
1005
  if (this.bitmapX >= 0 && this.bitmapX < 1024 && this.bitmapY < 625) {
976
1006
  let doubledLines = false;
977
- let offset = this.bitmapY;
1007
+ let bitmapRow = this.bitmapY;
978
1008
  // There's a painting subtlety here: if we're in an
979
1009
  // interlace mode but R6>R4 then we'll get stuck
980
1010
  // painting just an odd or even frame, so we double up
@@ -984,12 +1014,22 @@ export class Video {
984
1014
  this.isEvenRender === this.lastRenderWasEven
985
1015
  ) {
986
1016
  doubledLines = true;
987
- offset &= ~1;
1017
+ bitmapRow &= ~1;
988
1018
  }
989
1019
 
990
- offset = offset * 1024 + this.bitmapX;
1020
+ const offset = bitmapRow * 1024 + this.bitmapX;
991
1021
 
992
1022
  if ((this.dispEnabled & EVERYTHINGENABLED) === EVERYTHINGENABLED) {
1023
+ // Note this row's logical pixel size for display
1024
+ // filters; see video-filters/pixel-grid.js. The ULA half
1025
+ // of the descriptor is precomputed on register writes so
1026
+ // this stays a store or two in the hottest loop we have.
1027
+ if (doubledLines) {
1028
+ this.lineGrid[bitmapRow] = this.lineGridUlaDoubled;
1029
+ this.lineGrid[bitmapRow + 1] = this.lineGridUlaDoubled;
1030
+ } else {
1031
+ this.lineGrid[bitmapRow] = this.lineGridUla;
1032
+ }
993
1033
  if (this.teletextMode) {
994
1034
  if (this.halfClock) {
995
1035
  // Proper MODE 7 (1MHz clock + teletext): render SAA5050 output normally.
package/src/wd-fdc.js CHANGED
@@ -46,6 +46,17 @@ const CommandBits = Object.freeze({
46
46
  typeIIDeleted: 0x01,
47
47
  });
48
48
 
49
+ /**
50
+ * Type IV (force interrupt) condition bits, taken from the command's low nibble.
51
+ *
52
+ * @readonly
53
+ * @enum {Number}
54
+ */
55
+ const ForceInterruptBits = Object.freeze({
56
+ indexPulse: 0x04,
57
+ immediate: 0x08,
58
+ });
59
+
49
60
  /**
50
61
  * The drive control register is documented here:
51
62
  * https://www.cloud9.co.uk/james/BBCMicro/Documentation/wd1770.html
@@ -512,6 +523,8 @@ export class WdFdc {
512
523
  // insofar as index pulse appears to be reported in the status register.
513
524
  // - Interrupt on index pulse is only active for the current command.
514
525
  if (this._statusRegister & Status.busy) {
526
+ // Any pending timer belongs to the command being aborted.
527
+ this._clearTimer();
515
528
  this._commandDone(false);
516
529
  } else {
517
530
  if (this._state !== State.idle) throw new Error(`Unexpected state when force interrupt: ${this._state}`);
@@ -523,13 +536,10 @@ export class WdFdc {
523
536
  this._currentDrive.startSpinning();
524
537
  }
525
538
  }
526
- if (forceInterruptBits === 0) {
527
- this._isInterruptOnIndexPulse = false;
528
- } else if (forceInterruptBits === 4) {
529
- this._isInterruptOnIndexPulse = true;
530
- } else {
531
- throw new Error(`1700 force interrupt flags not handled: ${forceInterruptBits}`);
532
- }
539
+ if (forceInterruptBits & ForceInterruptBits.immediate) this._setIntRq(true);
540
+ this._isInterruptOnIndexPulse = !!(forceInterruptBits & ForceInterruptBits.indexPulse);
541
+ // The remaining two bits select interrupts on the ready line's transitions. The BBC ties
542
+ // the 1770's READY input active, so neither transition can ever occur.
533
543
  }
534
544
 
535
545
  _timerFired() {