mirror of
https://codeberg.org/vlw/crtjs.git
synced 2025-09-13 18:03:40 +02:00
Compare commits
5 commits
Author | SHA1 | Date | |
---|---|---|---|
0162af646d | |||
3a3afda430 | |||
db2ea6e24f | |||
fa0c46d5c7 | |||
3501d1b0fa |
12 changed files with 251 additions and 102 deletions
10
README.md
10
README.md
|
@ -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://github.com/VictorWesterlund/crtjs/deployments/activity_log?environment=github-pages)
|
||||
|
|
|
@ -1,43 +1,32 @@
|
|||
html,
|
||||
body {
|
||||
--size: 50px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: gray;
|
||||
}
|
||||
|
||||
html {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
body {
|
||||
--resolution: 200px;
|
||||
width: calc(var(--size) * 4);
|
||||
height: calc(var(--size) * 5);
|
||||
#screen {
|
||||
--blank-level: rgb(0,0,0);
|
||||
--line-level: rgb(var(--contrast),var(--contrast),var(--contrast));
|
||||
|
||||
width: calc(1px * var(--resolution-width));
|
||||
height: calc(1px * var(--resolution-height));
|
||||
transform: scale(var(--scale));
|
||||
background: black;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
#electron {
|
||||
position: absolute;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
background: red;
|
||||
/*animation: interpolate 1ms linear infinite;*/
|
||||
.pixel {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
background: var(--blank-level);
|
||||
}
|
||||
|
||||
@keyframes interpolate {
|
||||
@keyframes decay {
|
||||
to {
|
||||
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 */
|
||||
background: var(--blank-level);
|
||||
}
|
||||
}
|
18
index.html
18
index.html
|
@ -4,10 +4,22 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>
|
||||
<body>
|
||||
<div id="electron"></div>
|
||||
<script src="js/main.js"></script>
|
||||
<div id="screen"></div>
|
||||
<script src="js/main.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
69
js/main.js
69
js/main.js
|
@ -1,34 +1,43 @@
|
|||
const worker = new Worker("js/worker.js");
|
||||
import { FluorescentScreen } from "./modules/Screen.mjs";
|
||||
import { RasterScan } from "./modules/ElectronGun.mjs";
|
||||
|
||||
// Electron
|
||||
const ray = {
|
||||
electron: document.getElementById("electron"),
|
||||
get size() {
|
||||
const size = getComputedStyle(this.electron).getPropertyValue("--size");
|
||||
return parseInt(size);
|
||||
},
|
||||
set size(value) {
|
||||
this.electron.style.setProperty("--size",value + "px");
|
||||
this.value = value;
|
||||
class CRT {
|
||||
|
||||
constructor(element) {
|
||||
this.element = element;
|
||||
this.initScreen();
|
||||
this.raster = new RasterScan(this.screen.pixels);
|
||||
}
|
||||
|
||||
async fetchJSON(url) {
|
||||
const response = await fetch(url);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
loadTape(ref) {
|
||||
this.fetchJSON(ref + "/header.json").then(headers => {
|
||||
// Reinitialize player with resolution from tape header
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
ray.size = 50;
|
||||
|
||||
// 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})`);
|
||||
}
|
||||
|
||||
// Clock
|
||||
worker.postMessage(resolution);
|
||||
worker.addEventListener("message",event => {
|
||||
aim(event.data.x,event.data.y);
|
||||
});
|
||||
window.video = new CRT(document.getElementById("screen"));
|
||||
window.video.loadTape("tapes/sample");
|
||||
window.video.play();
|
||||
|
|
31
js/modules/ElectronGun.mjs
Normal file
31
js/modules/ElectronGun.mjs
Normal 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
4
js/modules/Helper.mjs
Normal 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
64
js/modules/Raster.mjs
Normal 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
36
js/modules/Screen.mjs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
41
js/worker.js
41
js/worker.js
|
@ -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
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
27
tapes/sample/generator.py
Normal 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
9
tapes/sample/header.json
Normal file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"manifest": "sample",
|
||||
"format": "signed-hex",
|
||||
"resolution": ["32","32"],
|
||||
"framerate": 100,
|
||||
"segments": [
|
||||
"data.json"
|
||||
]
|
||||
}
|
Loading…
Add table
Reference in a new issue