Merge pull request #1 from VictorWesterlund/develop/phosphor

Develop/phosphor
This commit is contained in:
Victor Westerlund 2021-01-26 06:11:36 +01:00 committed by GitHub
commit 3a3afda430
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 251 additions and 102 deletions

View file

@ -1 +1,9 @@
### [Demo](https://victorwesterlund.github.io/crtjs-test/) # Cathode Ray Tube (JS)
Simulating the fundimentals of a CRT display using pure JS (for fun).
Raster patterns are drawn onto a simulated phosphor display from virtual video tapes.
## Try it out
**Warning!** This thing might **really** bring your browser down on its knees. Setting the framerate too high might even break it completely (make it crash).
### [Live demo](https://victorwesterlund.github.io/crtjs-test/)

View file

@ -1,43 +1,32 @@
html, html,
body { body {
--size: 50px;
margin: 0; margin: 0;
overflow: hidden;
background: gray;
}
html {
height: 100%; height: 100%;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
background: #eee;
} }
body { #screen {
--resolution: 200px; --blank-level: rgb(0,0,0);
width: calc(var(--size) * 4); --line-level: rgb(var(--contrast),var(--contrast),var(--contrast));
height: calc(var(--size) * 5);
width: calc(1px * var(--resolution-width));
height: calc(1px * var(--resolution-height));
transform: scale(var(--scale));
background: black; background: black;
display: grid;
} }
#electron { .pixel {
position: absolute; width: 1px;
width: var(--size); height: 1px;
height: var(--size); background: var(--blank-level);
background: red;
/*animation: interpolate 1ms linear infinite;*/
} }
@keyframes interpolate { @keyframes decay {
to { to {
box-shadow: background: var(--blank-level);
0 -20px 0 red, 0 20px 0 red, /* y 1 */
0 -40px 0 red, 0 40px 0 red, /* y 2 */
0 -80px 0 red, 0 80px 0 red, /* y 3 */
0 -100px 0 red, 0 100px 0 red, /* y 4 */
-20px 0 0 red, 20px 0 0 red, /* x 1 */
-40px 0 0 red, 40px 0 0 red, /* x 2 */
-80px 0 0 red, 80px 0 0 red, /* x 3*/
-100px 0 0 red, 100px 0 0 red; /* x 4 */
} }
} }

View file

@ -4,10 +4,22 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<title>Cathode Ray</title> <title>CRT</title>
<style>
:root {
/* Screen */
--resolution-width: 64;
--resolution-height: 64;
--scale: 8;
/* Phosphor */
--contrast: 128;
--decay: 2s;
}
</style>
</head> </head>
<body> <body>
<div id="electron"></div> <div id="screen"></div>
<script src="js/main.js"></script> <script src="js/main.js" type="module"></script>
</body> </body>
</html> </html>

View file

@ -1,34 +1,43 @@
const worker = new Worker("js/worker.js"); import { FluorescentScreen } from "./modules/Screen.mjs";
import { RasterScan } from "./modules/ElectronGun.mjs";
// Electron class CRT {
const ray = {
electron: document.getElementById("electron"), constructor(element) {
get size() { this.element = element;
const size = getComputedStyle(this.electron).getPropertyValue("--size"); this.initScreen();
return parseInt(size); this.raster = new RasterScan(this.screen.pixels);
},
set size(value) {
this.electron.style.setProperty("--size",value + "px");
this.value = value;
}
} }
ray.size = 50; async fetchJSON(url) {
const response = await fetch(url);
// Screen resolution return response.json();
const resolution = {
width: 4,
height: 5
};
// Aim electron gun at pixel
function aim(x,y) {
const translate = `${x * ray.size}px,${y * ray.size}px`;
ray.electron.style.setProperty("transform",`translate(${translate})`);
} }
// Clock loadTape(ref) {
worker.postMessage(resolution); this.fetchJSON(ref + "/header.json").then(headers => {
worker.addEventListener("message",event => { // Reinitialize player with resolution from tape header
aim(event.data.x,event.data.y); if(headers.resolution[0] !== this.element.clientWidth || headers.resolution[1] !== this.element.clientHeight) {
this.element.style.setProperty("width",headers.resolution[0] + "px");
this.element.style.setProperty("height",headers.resolution[1] + "px");
this.initScreen();
this.raster.pixels = this.screen.pixels;
}
this.raster.load(headers);
}); });
}
play() {
this.raster.playstate("play");
}
initScreen() {
this.screen = new FluorescentScreen(this.element);
}
}
window.video = new CRT(document.getElementById("screen"));
window.video.loadTape("tapes/sample");
window.video.play();

View file

@ -0,0 +1,31 @@
export class RasterScan {
constructor(pixels) {
this.pixels = pixels;
this.signal = new Worker("./js/modules/Raster.mjs");
this.signal.addEventListener("message",event => {
this.fire(event.data);
});
}
fire(pixel) {
this.pixels[pixel.index].style.setProperty("background",pixel.color);
//this.pixels[data.pixel].style.setProperty("animation",`decay 10ms ${data.pixel} linear forwards`);
}
load(headers) {
this.signal.postMessage({
type: "headers",
payload: headers
});
}
playstate(state) {
this.signal.postMessage({
type: "playstate",
payload: state
});
}
}

4
js/modules/Helper.mjs Normal file
View file

@ -0,0 +1,4 @@
export async function fetchJSON(url) {
const response = await fetch(url);
return response.json();
}

64
js/modules/Raster.mjs Normal file
View file

@ -0,0 +1,64 @@
let meta = {
fields: 0,
pixels: 0,
framerate: 100
};
let clock = null;
let tape = null;
let stream = (stream) => {
meta.fields = Math.floor(stream.length / meta.pixels);
tape = stream;
return true;
}
async function fetchJSON(url) {
const response = await fetch(url);
return response.json();
}
function scan() {
let pixel = 0;
for(let head = 0; head < tape.length; head++) {
if(pixel == meta.pixels) {
pixel = 0;
}
self.postMessage({
index: pixel,
color: tape[head]
});
pixel++;
}
}
function playstate(state) {
if(state == "play") {
clock = setInterval(scan,meta.framerate);
return;
}
clock = null;
}
self.addEventListener("message",event => {
const payload = event.data.payload;
switch(event.data.type) {
case "headers":
meta.pixels = payload.resolution[0] * payload.resolution[1];
meta.framerate = payload.framerate;
const segmentURL = new Array("../../tapes",payload.manifest,payload.segments[0]).join("/");
fetchJSON(segmentURL).then(tape => stream(tape));
break;
case "playstate":
playstate(payload);
break;
default: console.warn("Unknown instruction: " + event.data.type); break;
}
});

36
js/modules/Screen.mjs Normal file
View file

@ -0,0 +1,36 @@
export class FluorescentScreen {
constructor(screen) {
this.screen = screen;
this.pixels = [];
this.destroyPixels();
this.spawnPixels();
}
createMatrix() {
this.screen.style.setProperty("grid-template-columns",`repeat(${this.screen.clientWidth},1px)`);
this.screen.style.setProperty("grid-template-rows",`repeat(${this.screen.clientHeight},1px)`);
}
spawnPixels() {
const density = this.screen.clientWidth * this.screen.clientHeight;
for(let i = 0; i < density; i++) {
const pixel = document.createElement("div");
pixel.classList.add("pixel");
this.screen.appendChild(pixel);
this.pixels.push(pixel);
}
this.createMatrix();
}
destroyPixels() {
while(this.screen.firstChild) {
this.screen.removeChild(this.screen.lastChild);
}
}
}

View file

@ -1,41 +0,0 @@
let resolution = {
width: 0,
height: 0
};
// Gun/deflector angle
const pos = {
x: 0,
y: 0,
get advance() {
this.x++;
// Hortizontal blank
if(this.x == resolution.width) {
this.x = 0;
this.y++;
}
// Vertical blank
if(this.y == resolution.height) {
this.y = 0;
}
return {
x: this.x,
y: this.y
}
}
};
const refresh = 1; // Refresh rate
let clock;
function scanline() {
self.postMessage(pos.advance);
}
self.addEventListener("message",event => {
resolution = event.data;
clock = setInterval(scanline,refresh);
});

1
tapes/sample/data.json Normal file

File diff suppressed because one or more lines are too long

27
tapes/sample/generator.py Normal file
View file

@ -0,0 +1,27 @@
import random
def rand():
dec = random.randint(0,255)
tohex = str(hex(dec).split("x")[-1])
if(dec < 16):
tohex = "0" + tohex
return tohex
def randomColor():
pixel = rand() + rand() + rand()
return f"\"#{pixel}\""
# ----
file = open("data.json","a+")
file.write("[")
# 3 Seconds of 64x64px@10fps uncompressed video (~7MB)
for x in range(737280):
file.write(randomColor() + ",")
file.write(randomColor())
file.write("]")
file.close()

9
tapes/sample/header.json Normal file
View file

@ -0,0 +1,9 @@
{
"manifest": "sample",
"format": "signed-hex",
"resolution": ["32","32"],
"framerate": 100,
"segments": [
"data.json"
]
}