mirror of
https://codeberg.org/vlw/crtjs.git
synced 2025-09-14 10:13:41 +02:00
Compare commits
No commits in common. "master" and "0.1.0" have entirely different histories.
12 changed files with 102 additions and 251 deletions
10
README.md
10
README.md
|
@ -1,9 +1 @@
|
||||||
# Cathode Ray Tube (JS)
|
### [Demo](https://victorwesterlund.github.io/crtjs-test/)
|
||||||
|
|
||||||
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://github.com/VictorWesterlund/crtjs/deployments/activity_log?environment=github-pages)
|
|
||||||
|
|
|
@ -1,32 +1,43 @@
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#screen {
|
body {
|
||||||
--blank-level: rgb(0,0,0);
|
--resolution: 200px;
|
||||||
--line-level: rgb(var(--contrast),var(--contrast),var(--contrast));
|
width: calc(var(--size) * 4);
|
||||||
|
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pixel {
|
#electron {
|
||||||
width: 1px;
|
position: absolute;
|
||||||
height: 1px;
|
width: var(--size);
|
||||||
background: var(--blank-level);
|
height: var(--size);
|
||||||
|
background: red;
|
||||||
|
/*animation: interpolate 1ms linear infinite;*/
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes decay {
|
@keyframes interpolate {
|
||||||
to {
|
to {
|
||||||
background: var(--blank-level);
|
box-shadow:
|
||||||
|
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 */
|
||||||
}
|
}
|
||||||
}
|
}
|
18
index.html
18
index.html
|
@ -4,22 +4,10 @@
|
||||||
<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>CRT</title>
|
<title>Cathode Ray</title>
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
/* Screen */
|
|
||||||
--resolution-width: 64;
|
|
||||||
--resolution-height: 64;
|
|
||||||
--scale: 8;
|
|
||||||
|
|
||||||
/* Phosphor */
|
|
||||||
--contrast: 128;
|
|
||||||
--decay: 2s;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="screen"></div>
|
<div id="electron"></div>
|
||||||
<script src="js/main.js" type="module"></script>
|
<script src="js/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
65
js/main.js
65
js/main.js
|
@ -1,43 +1,34 @@
|
||||||
import { FluorescentScreen } from "./modules/Screen.mjs";
|
const worker = new Worker("js/worker.js");
|
||||||
import { RasterScan } from "./modules/ElectronGun.mjs";
|
|
||||||
|
|
||||||
class CRT {
|
// Electron
|
||||||
|
const ray = {
|
||||||
constructor(element) {
|
electron: document.getElementById("electron"),
|
||||||
this.element = element;
|
get size() {
|
||||||
this.initScreen();
|
const size = getComputedStyle(this.electron).getPropertyValue("--size");
|
||||||
this.raster = new RasterScan(this.screen.pixels);
|
return parseInt(size);
|
||||||
|
},
|
||||||
|
set size(value) {
|
||||||
|
this.electron.style.setProperty("--size",value + "px");
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchJSON(url) {
|
ray.size = 50;
|
||||||
const response = await fetch(url);
|
|
||||||
return response.json();
|
// Screen resolution
|
||||||
|
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})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
loadTape(ref) {
|
// Clock
|
||||||
this.fetchJSON(ref + "/header.json").then(headers => {
|
worker.postMessage(resolution);
|
||||||
// Reinitialize player with resolution from tape header
|
worker.addEventListener("message",event => {
|
||||||
if(headers.resolution[0] !== this.element.clientWidth || headers.resolution[1] !== this.element.clientHeight) {
|
aim(event.data.x,event.data.y);
|
||||||
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();
|
|
||||||
|
|
|
@ -1,31 +0,0 @@
|
||||||
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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,4 +0,0 @@
|
||||||
export async function fetchJSON(url) {
|
|
||||||
const response = await fetch(url);
|
|
||||||
return response.json();
|
|
||||||
}
|
|
|
@ -1,64 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
|
|
@ -1,36 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
41
js/worker.js
Normal file
41
js/worker.js
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
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);
|
||||||
|
});
|
File diff suppressed because one or more lines are too long
|
@ -1,27 +0,0 @@
|
||||||
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()
|
|
|
@ -1,9 +0,0 @@
|
||||||
{
|
|
||||||
"manifest": "sample",
|
|
||||||
"format": "signed-hex",
|
|
||||||
"resolution": ["32","32"],
|
|
||||||
"framerate": 100,
|
|
||||||
"segments": [
|
|
||||||
"data.json"
|
|
||||||
]
|
|
||||||
}
|
|
Loading…
Add table
Reference in a new issue