Skip to content

Commit 360734f

Browse files
committedApr 2, 2023
Unified video filter
Closed #260
1 parent 808b818 commit 360734f

20 files changed

+601
-201
lines changed
 

‎.prettierrc.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
2+
"overrides": [{ "files": ["*.frag"], "options": { "parser": "glsl-parser" } }],
23
"semi": true,
34
"trailingComma": "all",
45
"singleQuote": true,
56
"printWidth": 120,
67
"endOfLine": "lf"
78
}
8-

‎.vscode/settings.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"editor.defaultFormatter": null
1414
},
1515
"[typescript]": {
16-
"editor.formatOnSave": false,
16+
"editor.formatOnSave": false
1717
},
1818
"editor.codeActionsOnSave": {
1919
"source.fixAll": true

‎package.json

+1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"lerna": "^6.5.1",
2323
"lint-staged": "^13.1.2",
2424
"prettier": "^2.8.4",
25+
"prettier-plugin-glsl": "^0.0.9",
2526
"typescript": "^4.9.5"
2627
},
2728
"lint-staged": {

‎packages/nes/src/lib.rs

-7
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,6 @@ impl Nes {
5454
Ppu::HEIGHT
5555
}
5656

57-
pub fn set_filter(&mut self, filter: &str) {
58-
self.control_deck.set_filter(match filter {
59-
"NTSC" => VideoFilter::Ntsc,
60-
_ => VideoFilter::Pixellate,
61-
});
62-
}
63-
6457
pub fn sound(&mut self) -> bool {
6558
self.sound
6659
}

‎packages/nes/utils_macro/src/lib.rs

-4
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,6 @@ pub fn nesbox_bevy(_: TokenStream, item: TokenStream) -> TokenStream {
4949
.height
5050
}
5151

52-
pub fn set_filter(&mut self, _filter: &str) {
53-
//
54-
}
55-
5652
pub fn sound(&mut self) -> bool {
5753
self.app
5854
.world

‎packages/sandbox/src/index.ts

-3
Original file line numberDiff line numberDiff line change
@@ -228,9 +228,6 @@ export class Nes implements ONes {
228228
reset() {
229229
//
230230
}
231-
set_filter(_filter: string) {
232-
//
233-
}
234231
set_sound(_enabled: boolean) {
235232
//
236233
}

‎packages/toolbox/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,6 @@
1919
"devDependencies": {
2020
"@types/offscreencanvas": "^2019.7.0",
2121
"@nesbox/config": "^0.0.1",
22-
"vite": "^4.1.4"
22+
"vite": "^4.2.1"
2323
}
2424
}

‎packages/webapp/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,6 @@
3333
"@types/marked": "^4.0.3",
3434
"dotenv": "^16.0.1",
3535
"network-information-types": "^0.1.1",
36-
"vite": "^4.1.4"
36+
"vite": "^4.2.1"
3737
}
3838
}

‎packages/webapp/src/constants.ts

+1
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ export const enum VideoRenderMethod {
132132
export const enum VideoFilter {
133133
DEFAULT = 'default',
134134
NTSC = 'NTSC',
135+
CRT = 'CRT',
135136
}
136137

137138
export const enum VideoRefreshRate {

‎packages/webapp/src/elements/canvas.ts

+229-22
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,47 @@ import {
55
customElement,
66
createCSSSheet,
77
css,
8-
connectStore,
98
RefObject,
109
refobject,
1110
numattribute,
11+
attribute,
1212
} from '@mantou/gem';
1313
import { BaseDirectory } from '@tauri-apps/api/fs';
1414
import { Time } from 'duoyun-ui/lib/time';
1515
import { saveFile } from 'src/utils';
1616

17-
import { configure } from 'src/configure';
17+
import { VideoFilter } from 'src/constants';
18+
import { logger } from 'src/logger';
19+
import normalVert from 'src/shaders/normal.vert?raw';
20+
21+
const getShader = (filter: VideoFilter) => {
22+
switch (filter) {
23+
case VideoFilter.NTSC: {
24+
return import('src/shaders/ntsc.frag?raw');
25+
}
26+
case VideoFilter.CRT: {
27+
return import('src/shaders/crt.frag?raw');
28+
}
29+
default: {
30+
return import('src/shaders/normal.frag?raw');
31+
}
32+
}
33+
};
34+
35+
const ortho = (left: number, right: number, bottom: number, top: number): number[] => {
36+
// prettier-ignore
37+
const m = [
38+
1.0, 0.0, 0.0, 0.0,
39+
0.0, 1.0, 0.0, 0.0,
40+
0.0, 0.0, 1.0, 0.0,
41+
0.0, 0.0, 0.0, 1.0,
42+
];
43+
m[0 * 4 + 0] = 2.0 / (right - left);
44+
m[1 * 4 + 1] = 2.0 / (top - bottom);
45+
m[3 * 4 + 0] = ((right + left) / (right - left)) * -1.0;
46+
m[3 * 4 + 1] = ((top + bottom) / (top - bottom)) * -1.0;
47+
return m;
48+
};
1849

1950
const style = createCSSSheet(css`
2051
:host {
@@ -33,30 +64,184 @@ const style = createCSSSheet(css`
3364
*/
3465
@adoptedStyle(style)
3566
@customElement('nesbox-canvas')
36-
@connectStore(configure)
3767
export class NesboxCanvasElement extends GemElement {
3868
@numattribute width: number;
3969
@numattribute height: number;
70+
@attribute filter: VideoFilter;
4071
@refobject canvasRef: RefObject<HTMLCanvasElement>;
4172

73+
#scale = 2;
74+
75+
get #renderWidth() {
76+
return this.width * this.#scale;
77+
}
78+
79+
get #renderHeight() {
80+
return this.height * this.#scale;
81+
}
82+
83+
// https://github.com/lukexor/tetanes/blob/main/web/www/src/index.ts#L60
84+
#webgl?: WebGL2RenderingContext;
85+
#time_uniform: WebGLUniformLocation | null;
86+
#frame_uniform: WebGLUniformLocation | null;
87+
#frame = 1;
88+
#setupWebGL = async () => {
89+
const width = this.width;
90+
const height = this.height;
91+
const max_size = Math.max(width, height);
92+
93+
const webgl = this.canvasRef.element!.getContext('webgl2');
94+
95+
if (!webgl) {
96+
logger.error('WebGL rendering context not found.');
97+
return null;
98+
}
99+
100+
const vertShader = webgl.createShader(webgl.VERTEX_SHADER);
101+
const fragShader = webgl.createShader(webgl.FRAGMENT_SHADER);
102+
103+
if (!vertShader || !fragShader) {
104+
logger.error('WebGL shader creation failed.');
105+
return null;
106+
}
107+
108+
webgl.shaderSource(vertShader, normalVert);
109+
webgl.shaderSource(fragShader, (await getShader(this.filter)).default);
110+
webgl.compileShader(vertShader);
111+
webgl.compileShader(fragShader);
112+
113+
if (!webgl.getShaderParameter(vertShader, webgl.COMPILE_STATUS)) {
114+
logger.error('WebGL vertex shader compilation failed:', webgl.getShaderInfoLog(vertShader));
115+
return null;
116+
}
117+
if (!webgl.getShaderParameter(fragShader, webgl.COMPILE_STATUS)) {
118+
logger.error('WebGL fragment shader compilation failed:', webgl.getShaderInfoLog(fragShader));
119+
return null;
120+
}
121+
122+
const program = webgl.createProgram();
123+
if (!program) {
124+
logger.error('WebGL program creation failed.');
125+
return null;
126+
}
127+
128+
webgl.attachShader(program, vertShader);
129+
webgl.attachShader(program, fragShader);
130+
webgl.linkProgram(program);
131+
if (!webgl.getProgramParameter(program, webgl.LINK_STATUS)) {
132+
logger.error('WebGL program linking failed!');
133+
return null;
134+
}
135+
136+
webgl.useProgram(program);
137+
138+
const vertex_attr = webgl.getAttribLocation(program, 'a_position');
139+
const texcoord_attr = webgl.getAttribLocation(program, 'a_texcoord');
140+
webgl.enableVertexAttribArray(vertex_attr);
141+
webgl.enableVertexAttribArray(texcoord_attr);
142+
143+
const samplerUint = 0;
144+
const sampler_uniform = webgl.getUniformLocation(program, 'u_sampler');
145+
webgl.uniform1i(sampler_uniform, samplerUint);
146+
147+
const matrix_uniform = webgl.getUniformLocation(program, 'u_matrix');
148+
webgl.uniformMatrix4fv(matrix_uniform, false, ortho(0.0, width, height, 0.0));
149+
150+
const resolution_uniform = webgl.getUniformLocation(program, 'u_resolution');
151+
if (resolution_uniform) webgl.uniform3f(resolution_uniform, this.width, this.height, 1);
152+
153+
// deps on frame uniforms
154+
this.#time_uniform = webgl.getUniformLocation(program, 'u_time');
155+
this.#frame_uniform = webgl.getUniformLocation(program, 'u_frame');
156+
157+
const texture = webgl.createTexture();
158+
webgl.activeTexture(webgl.TEXTURE0 + samplerUint);
159+
webgl.bindTexture(webgl.TEXTURE_2D, texture);
160+
webgl.texImage2D(
161+
webgl.TEXTURE_2D,
162+
0,
163+
webgl.RGBA,
164+
max_size,
165+
max_size,
166+
0,
167+
webgl.RGBA,
168+
webgl.UNSIGNED_BYTE,
169+
new Uint8Array(max_size * max_size * 4),
170+
);
171+
webgl.texParameteri(webgl.TEXTURE_2D, webgl.TEXTURE_MAG_FILTER, webgl.NEAREST);
172+
webgl.texParameteri(webgl.TEXTURE_2D, webgl.TEXTURE_MIN_FILTER, webgl.NEAREST);
173+
174+
const vertex_buffer = webgl.createBuffer();
175+
webgl.bindBuffer(webgl.ARRAY_BUFFER, vertex_buffer);
176+
// prettier-ignore
177+
const vertices = [
178+
0.0, 0.0,
179+
0.0, height,
180+
width, 0.0,
181+
width, height,
182+
];
183+
webgl.bufferData(webgl.ARRAY_BUFFER, new Float32Array(vertices), webgl.STATIC_DRAW);
184+
webgl.vertexAttribPointer(vertex_attr, 2, webgl.FLOAT, false, 0, 0);
185+
186+
const texcoord_buffer = webgl.createBuffer();
187+
webgl.bindBuffer(webgl.ARRAY_BUFFER, texcoord_buffer);
188+
// prettier-ignore
189+
const texcoords = [
190+
0.0, 0.0,
191+
0.0, height / width,
192+
1.0, 0.0,
193+
1.0, height / width,
194+
];
195+
webgl.bufferData(webgl.ARRAY_BUFFER, new Float32Array(texcoords), webgl.STATIC_DRAW);
196+
webgl.vertexAttribPointer(texcoord_attr, 2, webgl.FLOAT, false, 0, 0);
197+
198+
const index_buffer = webgl.createBuffer();
199+
webgl.bindBuffer(webgl.ELEMENT_ARRAY_BUFFER, index_buffer);
200+
// vertices index
201+
// prettier-ignore
202+
const indices = [
203+
0, 1, 2,
204+
2, 3, 1,
205+
];
206+
webgl.bufferData(webgl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indices), webgl.STATIC_DRAW);
207+
208+
webgl.clear(webgl.COLOR_BUFFER_BIT);
209+
webgl.enable(webgl.DEPTH_TEST);
210+
webgl.viewport(0, 0, this.#renderWidth, this.#renderHeight);
211+
212+
return webgl;
213+
};
214+
42215
paint = (frame: Uint8Array, part?: number[]) => {
43-
if (!this.#imageData) return;
44-
const { data } = this.#imageData;
45-
if (part) {
46-
const [x, y, _, h] = part;
47-
// NOTE: current only support full width
48-
const w = this.width;
49-
for (let i = 0; i < h; i++) {
50-
const line = new Uint8Array(frame.buffer, frame.byteOffset + i * w * 4, w * 4);
51-
data.set(line, ((i + y) * this.width + x) * 4);
52-
}
53-
} else {
54-
data.set(frame);
216+
if (!this.#webgl) return;
217+
218+
if (this.#time_uniform) {
219+
this.#webgl.uniform1f(this.#time_uniform, performance.now());
220+
}
221+
if (this.#frame_uniform) {
222+
this.#webgl.uniform1i(this.#frame_uniform, this.#frame++);
55223
}
56-
this.canvasRef.element!.getContext('2d')!.putImageData(this.#imageData, 0, 0);
224+
225+
// only support full width
226+
const [x, y, __, h] = part || [0, 0, this.width, this.height];
227+
this.#webgl.texSubImage2D(
228+
this.#webgl.TEXTURE_2D,
229+
0,
230+
x,
231+
y,
232+
this.width,
233+
h,
234+
this.#webgl.RGBA,
235+
this.#webgl.UNSIGNED_BYTE,
236+
frame,
237+
);
238+
this.#webgl.drawElements(this.#webgl.TRIANGLES, 6, this.#webgl.UNSIGNED_SHORT, 0);
239+
240+
this.#tasks.forEach((task) => task());
241+
this.#tasks.length = 0;
57242
};
58243

59-
screenshot = () => {
244+
#screenshot = () => {
60245
return new Promise<BaseDirectory | undefined>((res) => {
61246
this.canvasRef.element!.toBlob(
62247
(blob) => blob && res(saveFile(new File([blob], `Screenshot ${new Time().format()}.png`))),
@@ -66,20 +251,42 @@ export class NesboxCanvasElement extends GemElement {
66251
});
67252
};
68253

254+
#tasks: (() => void)[] = [];
255+
256+
screenshot = () => {
257+
return new Promise((res, rej) => {
258+
this.#tasks.push(async () => {
259+
try {
260+
res(await this.#screenshot());
261+
} catch (err) {
262+
rej(err);
263+
}
264+
});
265+
});
266+
};
267+
69268
captureThumbnail = () => {
70-
return this.canvasRef.element!.toDataURL('image/png', 0.5);
269+
return new Promise<string>((res) => {
270+
this.#tasks.push(() => {
271+
res(this.canvasRef.element!.toDataURL('image/png', 0.5));
272+
});
273+
});
71274
};
72275

73-
#imageData?: ImageData;
74276
mounted = () => {
75-
this.effect(() => {
277+
this.effect(async () => {
76278
if (this.width) {
77-
this.#imageData = new ImageData(this.width, this.height);
279+
const webgl = await this.#setupWebGL();
280+
if (webgl) {
281+
this.#webgl = webgl;
282+
}
78283
}
79284
});
80285
};
81286

82287
render = () => {
83-
return html`<canvas class="canvas" width=${this.width} height=${this.height} ref=${this.canvasRef.ref}></canvas>`;
288+
return html`
289+
<canvas class="canvas" width=${this.#renderWidth} height=${this.#renderHeight} ref=${this.canvasRef.ref}></canvas>
290+
`;
84291
};
85292
}

‎packages/webapp/src/modules/stage.ts

+10-10
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,14 @@ import { clamp } from 'duoyun-ui/lib/number';
2020
import { isMtApp } from 'mt-app';
2121
import { getCDNSrc, isValidGameFile, playHintSound, positionMapping, requestFrame } from 'src/utils';
2222

23-
import { CustomGamepadButton, globalEvents, gameStateType, RTCTransportType } from 'src/constants';
23+
import {
24+
CustomGamepadButton,
25+
globalEvents,
26+
gameStateType,
27+
RTCTransportType,
28+
VideoFilter,
29+
VideoRenderMethod,
30+
} from 'src/constants';
2431
import { logger } from 'src/logger';
2532
import { Cheat, configure } from 'src/configure';
2633
import {
@@ -315,7 +322,6 @@ export class MStageElement extends GemElement<State> {
315322
// game = Nes.new(this.#sampleRate);
316323
// }
317324

318-
this.#setVideoFilter();
319325
this.setState({ canvasWidth: game.width(), canvasHeight: game.height() });
320326
this.#game = game;
321327
if (this.#isHost) {
@@ -504,10 +510,6 @@ export class MStageElement extends GemElement<State> {
504510
this.#releaseButton(button.player, button.btn);
505511
};
506512

507-
#setVideoFilter = () => {
508-
this.#settings && this.#game?.set_filter(this.#settings.video.filter);
509-
};
510-
511513
mounted = () => {
512514
this.effect(
513515
() => {
@@ -562,8 +564,6 @@ export class MStageElement extends GemElement<State> {
562564
() => [this.#rom],
563565
);
564566

565-
this.effect(this.#setVideoFilter, () => [this.#game, this.#settings?.video]);
566-
567567
this.effect(
568568
(muteArgs) => (muteArgs!.every((e) => !e) ? this.#resumeAudio() : this.#pauseAudio()),
569569
() => [!configure.windowHasFocus, configure.searchState, configure.settingsState, configure.friendListState],
@@ -598,9 +598,10 @@ export class MStageElement extends GemElement<State> {
598598
ref=${this.canvasRef.ref}
599599
.width=${canvasWidth}
600600
.height=${canvasHeight}
601+
.filter=${this.#settings?.video.filter || VideoFilter.DEFAULT}
601602
style=${styleMap({
602603
padding: isMtApp ? '2em 5em 5em' : '5em',
603-
imageRendering: configure.user ? configure.user.settings.video.render : 'pixelated',
604+
imageRendering: this.#settings?.video.render || VideoRenderMethod.PIXELATED,
604605
})}
605606
></nesbox-canvas>
606607
<audio ref=${this.audioRef.ref} hidden></audio>
@@ -664,7 +665,6 @@ export class MStageElement extends GemElement<State> {
664665
} else {
665666
this.#game.load_state(stateArray);
666667
}
667-
this.#setVideoFilter();
668668
};
669669

670670
getRam = () => {

‎packages/webapp/src/modules/ui-settings.ts

+9-9
Original file line numberDiff line numberDiff line change
@@ -51,15 +51,6 @@ export class MUiSettingsElement extends GemElement {
5151
}))}
5252
@change=${({ detail }: CustomEvent<ThemeName>) => changeTheme(detail)}
5353
></dy-select>
54-
${'startViewTransition' in document
55-
? html`
56-
<div>Transition</div>
57-
<dy-switch
58-
.checked=${!!configure.user?.settings.ui.viewTransition}
59-
@change=${({ detail }: CustomEvent<boolean>) => this.#updateVideoSetting('viewTransition', detail)}
60-
></dy-switch>
61-
`
62-
: ''}
6354
${window.__TAURI__ && location.hostname !== 'localhost'
6455
? html`
6556
<div>${i18n.get('branch')}</div>
@@ -73,6 +64,15 @@ export class MUiSettingsElement extends GemElement {
7364
></dy-select>
7465
`
7566
: ''}
67+
${'startViewTransition' in document
68+
? html`
69+
<div>Transition</div>
70+
<dy-switch
71+
.checked=${!!configure.user?.settings.ui.viewTransition}
72+
@change=${({ detail }: CustomEvent<boolean>) => this.#updateVideoSetting('viewTransition', detail)}
73+
></dy-switch>
74+
`
75+
: ''}
7676
</div>
7777
`;
7878
};

‎packages/webapp/src/modules/video-settings.ts

+2-6
Original file line numberDiff line numberDiff line change
@@ -54,17 +54,13 @@ export class MVideoSettingsElement extends GemElement {
5454
]}
5555
@change=${(evt: CustomEvent<VideoRenderMethod>) => this.#updateVideoSetting('render', evt.detail)}
5656
></dy-select>
57-
<div>
58-
${i18n.get('videoFilter')}
59-
<dy-tooltip .content=${i18n.get('tipHostSetting')}>
60-
<dy-use class="help" .element=${icons.help}></dy-use>
61-
</dy-tooltip>
62-
</div>
57+
<div>${i18n.get('videoFilter')}</div>
6358
<dy-select
6459
.value=${configure.user.settings.video.filter}
6560
.options=${[
6661
{ label: i18n.get('videoFilterDefault'), value: VideoFilter.DEFAULT },
6762
{ label: 'NTSC', value: VideoFilter.NTSC },
63+
{ label: 'CRT', value: VideoFilter.CRT },
6864
]}
6965
@change=${(evt: CustomEvent<VideoFilter>) => this.#updateVideoSetting('filter', evt.detail)}
7066
></dy-select>

‎packages/webapp/src/pages/mt-room.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,11 @@ export class PMtRoomElement extends GemElement {
6464
});
6565
}
6666

67-
#uploadScreenshot = () => {
67+
#uploadScreenshot = async () => {
6868
if (!this.stageRef.element!.hostRomBuffer) return;
6969
updateRoomScreenshot({
7070
id: this.#playing!.id,
71-
screenshot: this.stageRef.element!.getThumbnail(),
71+
screenshot: await this.stageRef.element!.getThumbnail(),
7272
});
7373
};
7474

‎packages/webapp/src/pages/room.ts

+6-4
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ export class PRoomElement extends GemElement {
191191
if (!this.stageRef.element!.hostRomBuffer) return;
192192
const state = this.stageRef.element!.getState();
193193
if (!state) return;
194-
const thumbnail = this.stageRef.element!.getThumbnail();
194+
const thumbnail = await this.stageRef.element!.getThumbnail();
195195
const cache = await caches.open(this.#getCachesName(auto));
196196
const key = await hash(this.stageRef.element!.hostRomBuffer);
197197
await cache.put(
@@ -216,7 +216,9 @@ export class PRoomElement extends GemElement {
216216
if (!auto) Toast.open('success', i18n.get('tipGameStateSave', new Time().format()));
217217
};
218218

219-
#autoSave = () => this.#save(true);
219+
#autoSave = () => {
220+
this.#save(true);
221+
};
220222

221223
#load = async () => {
222224
if (!this.stageRef.element!.hostRomBuffer) return;
@@ -269,11 +271,11 @@ export class PRoomElement extends GemElement {
269271
}
270272
};
271273

272-
#uploadScreenshot = () => {
274+
#uploadScreenshot = async () => {
273275
if (!this.stageRef.element!.hostRomBuffer) return;
274276
updateRoomScreenshot({
275277
id: this.#playing!.id,
276-
screenshot: this.stageRef.element!.getThumbnail(),
278+
screenshot: await this.stageRef.element!.getThumbnail(),
277279
});
278280
};
279281

‎packages/webapp/src/shaders/crt.frag

+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
#version 300 es
2+
3+
// https://www.shadertoy.com/view/XtlSD7
4+
5+
precision mediump float;
6+
7+
in vec2 v_texcoord;
8+
out vec4 frag_color;
9+
10+
uniform sampler2D u_sampler;
11+
uniform float u_time;
12+
13+
vec2 crt_curve_uv(vec2 uv) {
14+
uv = uv * 2.0 - 1.0;
15+
vec2 offset = abs(uv.yx) / vec2(6.0, 8.0);
16+
uv = uv + uv * offset * offset;
17+
uv = uv * 0.5 + 0.5;
18+
return uv;
19+
}
20+
21+
void draw_vignette(inout vec3 color, vec2 uv) {
22+
float vignette = uv.x * uv.y * (1.0 - uv.x) * (1.0 - uv.y);
23+
vignette = clamp(pow(16.0 * vignette, 0.1), 0.0, 1.0);
24+
color *= vignette;
25+
}
26+
27+
void draw_scanline(inout vec3 color, vec2 uv) {
28+
float scanline = clamp(0.95 + 0.05 * cos(3.14 * (uv.y + 0.008 * u_time) * 240.0 * 1.0), 0.0, 1.0);
29+
float grille = 0.86 + 0.14 * clamp(0.14 * cos(3.14 * uv.x * 640.0 * 1.0), 0.0, 1.0);
30+
color *= scanline * grille * 1.2;
31+
}
32+
33+
void main() {
34+
vec3 color = texture(u_sampler, vec2(v_texcoord.s, v_texcoord.t)).rgb;
35+
36+
vec2 crt_uv = crt_curve_uv(v_texcoord);
37+
if (crt_uv.x < 0.0 || crt_uv.x > 1.0 || crt_uv.y < 0.0 || crt_uv.y > 1.0) {
38+
color = vec3(0.0, 0.0, 0.0);
39+
}
40+
draw_vignette(color, crt_uv);
41+
42+
draw_scanline(color, v_texcoord);
43+
44+
frag_color = vec4(color, 1.0);
45+
}
+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
#version 300 es
2+
3+
precision mediump float;
4+
5+
in vec2 v_texcoord;
6+
out vec4 frag_color;
7+
8+
uniform sampler2D u_sampler;
9+
10+
void main() {
11+
frag_color = vec4(texture(u_sampler, vec2(v_texcoord.s, v_texcoord.t)).rgb, 1.0);
12+
}
+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#version 300 es
2+
3+
in vec2 a_position;
4+
in vec2 a_texcoord;
5+
out vec2 v_texcoord;
6+
out vec2 v_position;
7+
8+
uniform mat4 u_matrix;
9+
10+
void main() {
11+
gl_Position = u_matrix * vec4(a_position, 0.0, 1.0);
12+
v_texcoord = a_texcoord;
13+
v_position = a_position;
14+
}

‎packages/webapp/src/shaders/ntsc.frag

+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
#version 300 es
2+
3+
precision mediump float;
4+
5+
in vec2 v_texcoord;
6+
out vec4 frag_color;
7+
8+
uniform sampler2D u_sampler;
9+
10+
// https://www.shadertoy.com/view/lsfGD2
11+
12+
float sat(float t) {
13+
return clamp(t, 0.0, 1.0);
14+
}
15+
16+
vec3 spectrum_offset(float t) {
17+
float t0 = 3.0 * t - 1.5;
18+
return clamp(vec3(-t0, 1.0 - abs(t0), t0), 0.0, 1.0);
19+
}
20+
21+
float rand(vec2 n) {
22+
return fract(sin(dot(n.xy, vec2(12.9898, 78.233))) * 43758.5453);
23+
}
24+
25+
void main() {
26+
const float GLITCH = 0.1;
27+
const int NUM_SAMPLES = 3;
28+
const float RCP_NUM_SAMPLES_F = 1.0 / float(NUM_SAMPLES);
29+
30+
vec2 uv = v_texcoord;
31+
32+
float rnd0 = rand(vec2(1.0));
33+
float r0 = sat((1.0 - GLITCH) * 0.7 + rnd0);
34+
35+
float pxrnd = rand(uv);
36+
37+
float ofs = 0.05 * r0 * GLITCH * (rnd0 > 0.5 ? 1.0 : -1.0);
38+
ofs += 0.5 * pxrnd * ofs;
39+
40+
vec4 sum = vec4(0.0);
41+
vec3 wsum = vec3(0.0);
42+
for (int i = 0; i < NUM_SAMPLES; ++i) {
43+
float t = float(i) * RCP_NUM_SAMPLES_F;
44+
uv.x = sat(uv.x + ofs * t);
45+
vec4 samplecol = texture(u_sampler, uv, -10.0);
46+
vec3 s = spectrum_offset(t);
47+
samplecol.rgb = samplecol.rgb * s;
48+
sum += samplecol;
49+
wsum += s;
50+
}
51+
sum.rgb /= wsum;
52+
sum.a *= RCP_NUM_SAMPLES_F;
53+
54+
frag_color.a = sum.a;
55+
frag_color.rgb = sum.rgb;
56+
}

‎yarn.lock

+210-130
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)
Please sign in to comment.