shithub: wipeout

Download patch

ref: c0019b1bca62126c3ad709b6ce16a382afe6bcee
parent: 7a9f757a79d5c6806252cc1268bda5cdef463e23
author: Dominic Szablewski <[email protected]>
date: Sun Aug 13 10:02:19 EDT 2023

Add CRT post processing effect and allow for lower internal resolutions; see #1

--- a/src/render.h
+++ b/src/render.h
@@ -8,6 +8,18 @@
 	RENDER_BLEND_LIGHTER
 } render_blend_mode_t;
 
+typedef enum {
+	RENDER_RES_NATIVE,
+	RENDER_RES_240P,
+	RENDER_RES_480P,
+} render_resolution_t;
+
+typedef enum {
+	RENDER_POST_NONE,
+	RENDER_POST_CRT,
+	NUM_RENDER_POST_EFFCTS,
+} render_post_effect_t;
+
 #define RENDER_USE_MIPMAPS 1
 
 #define RENDER_FADEOUT_NEAR 48000.0
@@ -15,10 +27,12 @@
 
 extern uint16_t RENDER_NO_TEXTURE;
 
-void render_init(vec2i_t size);
+void render_init(vec2i_t screen_size);
 void render_cleanup();
 
-void render_resize(vec2i_t size);
+void render_set_screen_size(vec2i_t size);
+void render_set_resolution(render_resolution_t res);
+void render_set_post_effect(render_post_effect_t post);
 vec2i_t render_size();
 
 void render_frame_prepare();
--- a/src/render_gl.c
+++ b/src/render_gl.c
@@ -19,6 +19,10 @@
 		void glCreateTextures(GLuint ignored, GLsizei n, GLuint *name) {
 			glGenTextures(1, name);
 		}
+
+		void glFramebufferTexture(GLenum target, GLenum attachment, GLuint texture, GLint level) {
+			glFramebufferTexture2D(target, attachment, GL_TEXTURE_2D, texture, level);
+		}
 	#endif
 
 // WINDOWS
@@ -37,14 +41,12 @@
 
 #include "libs/stb_image_write.h"
 
+#include "system.h"
 #include "render.h"
 #include "mem.h"
 #include "utils.h"
 
 
-#define NEAR_PLANE 16.0
-#define FAR_PLANE 262144.0
-
 #define ATLAS_SIZE 64
 #define ATLAS_GRID 32
 #define ATLAS_BORDER 16
@@ -52,12 +54,24 @@
 #define RENDER_TRIS_BUFFER_CAPACITY 2048
 #define TEXTURES_MAX 1024
 
-// WebGL (GLES) needs the `precision` to be set, OpenGL 2.something 
-// doesn't like that...
+
 #ifdef __EMSCRIPTEN__
+	// WebGL (GLES) needs the `precision` to be set, wheras OpenGL 2 
+	// doesn't like that...
 	#define SHADER_SOURCE(...) "precision highp float;" #__VA_ARGS__
+
+	// WebGL1 only allows for a 16 bit depth buffer attachment, so 
+	// we sacrifice a bit of the near plane to get more precision
+	// further out
+	#define NEAR_PLANE 128.0
+	#define FAR_PLANE (RENDER_FADEOUT_FAR)
+	#define RENDER_DEPTH_BUFFER_INTERNAL_FORMAT GL_DEPTH_COMPONENT16
 #else
 	#define SHADER_SOURCE(...) #__VA_ARGS__
+
+	#define NEAR_PLANE 16.0
+	#define FAR_PLANE (RENDER_FADEOUT_FAR)
+	#define RENDER_DEPTH_BUFFER_INTERNAL_FORMAT GL_DEPTH_COMPONENT24
 #endif
 	
 
@@ -68,40 +82,74 @@
 
 uint16_t RENDER_NO_TEXTURE;
 
-static GLuint u_color;
-static GLuint u_view;
-static GLuint u_model;
-static GLuint u_projection;
-static GLuint u_screen;
-static GLuint u_camera_pos;
-static GLuint u_fade;
+#define use_program(SHADER) \
+	glUseProgram((SHADER)->program); \
+	glBindVertexArray((SHADER)->vao);
 
-static GLuint a_pos;
-static GLuint a_uv;
-static GLuint a_color;
+#define bind_va_f(index, container, member, start) \
+	glVertexAttribPointer( \
+		index, member_size(container, member)/sizeof(float), GL_FLOAT, false, \
+		sizeof(container), \
+		(GLvoid*)(offsetof(container, member) + start) \
+	)
 
-static GLuint vbo;
+#define bind_va_color(index, container, member, start) \
+	glVertexAttribPointer( \
+		index, 4,  GL_UNSIGNED_BYTE, true, \
+		sizeof(container), \
+		(GLvoid*)(offsetof(container, member) + start) \
+	)
 
-static tris_t tris_buffer[RENDER_TRIS_BUFFER_CAPACITY];
-static uint32_t tris_len = 0;
 
-static vec2i_t screen_size;
+static GLuint compile_shader(GLenum type, const char *source) {
+	GLuint shader = glCreateShader(type);
+	glShaderSource(shader, 1, &source, NULL);
+	glCompileShader(shader);
+	
+	GLint success;
+	glGetShaderiv(shader, GL_COMPILE_STATUS, &success);
+	if (!success) {
+		int log_written;
+		char log[256];
+		glGetShaderInfoLog(shader, 256, &log_written, log);
+		die("Error compiling shader: %s\n", log);
+	}
+	return shader;
+}
 
-static uint32_t atlas_map[ATLAS_SIZE] = {0};
-static GLuint atlas_texture = 0;
-static render_blend_mode_t blend_mode = RENDER_BLEND_NORMAL;
+static GLuint create_program(const char *vs_source, const char *fs_source) {
+	GLuint vs = compile_shader(GL_VERTEX_SHADER, vs_source);
+	GLuint fs = compile_shader(GL_FRAGMENT_SHADER, fs_source);
 
-static mat4_t projection_mat_2d = mat4_identity();
-static mat4_t projection_mat_3d = mat4_identity();
-static mat4_t sprite_mat = mat4_identity();
-static mat4_t view_mat = mat4_identity();
+	GLuint program = glCreateProgram();
+	glAttachShader(program, vs);
+	glAttachShader(program, fs);
+	glLinkProgram(program);
 
+	GLint linked;
+    glGetProgramiv(program, GL_LINK_STATUS, &linked);
+    if (!linked) {
+        #ifdef CRTEMU_REPORT_SHADER_ERRORS
+            char error_message[256];
+            strcpy(error_message, prefix);
+            int len = 0, written = 0;
+            glGetShaderiv(vs, GL_INFO_LOG_LENGTH, &len);
+            glGetShaderInfoLog(programm, (GLsizei)( sizeof(error_message)), &written, error_message);
+            die("Shader Link Error: %s" error_message);
+        #endif
+        return 0;
+    }
 
-static render_texture_t textures[TEXTURES_MAX];
-static uint32_t textures_len = 0;
-static bool texture_mipmap_is_dirty = false;
+	glUseProgram(program);
+	return program;
+}
 
-static const char * const VERTEX_SHADER = SHADER_SOURCE(
+
+
+// -----------------------------------------------------------------------------
+// Main game shaders
+
+static const char * const SHADER_GAME_VS = SHADER_SOURCE(
 	attribute vec3 pos;
 	attribute vec2 uv;
 	attribute vec4 color;
@@ -114,6 +162,7 @@
 	uniform vec2 screen;
 	uniform vec3 camera_pos;
 	uniform vec2 fade;
+	uniform float time;
 	
 	void main() {
 		gl_Position = projection * view * model * vec4(pos, 1.0);
@@ -128,10 +177,11 @@
 	}
 );
 
-static const char * const FRAGMENT_SHADER_YCRCB = SHADER_SOURCE(
+static const char * const SHADER_GAME_FS = SHADER_SOURCE(
 	varying vec4 v_color;
 	varying vec2 v_uv;
 	uniform sampler2D texture;
+
 	void main() {
 		vec4 tex_color = texture2D(texture, v_uv);
 		vec4 color = tex_color * v_color;
@@ -143,45 +193,232 @@
 	}
 );
 
+typedef struct {
+	GLuint program;
+	GLuint vao;
+	struct {
+		GLuint view;
+		GLuint model;
+		GLuint projection;
+		GLuint screen;
+		GLuint camera_pos;
+		GLuint fade;
+		GLuint time;
+	} uniform;
+	struct {
+		GLuint pos;
+		GLuint uv;
+		GLuint color;
+	} attribute;
+} prg_game_t;
 
-#define render_bind_va_f(index, container, member, start) \
-	glVertexAttribPointer( \
-		index, member_size(container, member)/sizeof(float), GL_FLOAT, false, \
-		sizeof(container), \
-		(GLvoid*)(offsetof(container, member) + start) \
-	)
+prg_game_t *shader_game_init() {
+	prg_game_t *s = mem_bump(sizeof(prg_game_t));
+	
+	s->program = create_program(SHADER_GAME_VS, SHADER_GAME_FS);
 
-#define render_bind_va_color(index, container, member, start) \
-	glVertexAttribPointer( \
-		index, 4,  GL_UNSIGNED_BYTE, true, \
-		sizeof(container), \
-		(GLvoid*)(offsetof(container, member) + start) \
-	)
+	s->uniform.view = glGetUniformLocation(s->program, "view");
+	s->uniform.model = glGetUniformLocation(s->program, "model");
+	s->uniform.projection = glGetUniformLocation(s->program, "projection");
+	s->uniform.screen = glGetUniformLocation(s->program, "screen");
+	s->uniform.camera_pos = glGetUniformLocation(s->program, "camera_pos");
+	s->uniform.fade = glGetUniformLocation(s->program, "fade");
 
-static void render_flush();
-static GLuint compile_shader(GLenum type, const char *source);
+	s->attribute.pos = glGetAttribLocation(s->program, "pos");
+	s->attribute.uv = glGetAttribLocation(s->program, "uv");
+	s->attribute.color = glGetAttribLocation(s->program, "color");
 
-static GLuint compile_shader(GLenum type, const char *source) {
-	GLuint shader = glCreateShader(type);
-	glShaderSource(shader, 1, &source, NULL);
-	glCompileShader(shader);
+	glGenVertexArrays(1, &s->vao);
+	glBindVertexArray(s->vao);
+
+	glEnableVertexAttribArray(s->attribute.pos);
+	glEnableVertexAttribArray(s->attribute.uv);
+	glEnableVertexAttribArray(s->attribute.color);
+
+	bind_va_f(s->attribute.pos, vertex_t, pos, 0);
+	bind_va_f(s->attribute.uv, vertex_t, uv, 0);
+	bind_va_color(s->attribute.color, vertex_t, color, 0);
+
+	return s;
+}
+
+
+// -----------------------------------------------------------------------------
+// POST Effect shaders
+
+static const char * const SHADER_POST_VS = SHADER_SOURCE(
+	attribute vec3 pos;
+	attribute vec2 uv;
+
+	varying vec2 v_uv;
+
+	uniform mat4 projection;
+	uniform vec2 screen_size;
+	uniform float time;
 	
-	GLint success;
-	glGetShaderiv(shader, GL_COMPILE_STATUS, &success);
-	if (!success) {
-		int log_written;
-		char log[256];
-		glGetShaderInfoLog(shader, 256, &log_written, log);
-		die("Error compiling shader: %s\n", log);
+	void main() {
+		gl_Position = projection * vec4(pos, 1.0);
+		v_uv = uv;
 	}
-	return shader;
+);
+
+static const char * const SHADER_POST_FS_DEFAULT = SHADER_SOURCE(
+	varying vec2 v_uv;
+
+	uniform sampler2D texture;
+	uniform vec2 screen_size;
+
+	void main() {
+		gl_FragColor = texture2D(texture, v_uv);
+	}
+);
+
+// CRT effect based on https://www.shadertoy.com/view/Ms23DR 
+// by https://github.com/mattiasgustavsson/
+static const char * const SHADER_POST_FS_CRT = SHADER_SOURCE(
+	varying vec2 v_uv;
+
+	uniform float time;
+	uniform sampler2D texture;
+	uniform vec2 screen_size;
+
+	vec2 curve(vec2 uv) {
+		uv = (uv - 0.5) * 2.0;
+		uv *= 1.1;	
+		uv.x *= 1.0 + pow((abs(uv.y) / 5.0), 2.0);
+		uv.y *= 1.0 + pow((abs(uv.x) / 4.0), 2.0);
+		uv  = (uv / 2.0) + 0.5;
+		uv =  uv *0.92 + 0.04;
+		return uv;
+	}
+
+	void main(){
+		vec2 uv = curve(gl_FragCoord.xy / screen_size);
+		vec3 color;
+		float x =  sin(0.3*time+uv.y*21.0)*sin(0.7*time+uv.y*29.0)*sin(0.3+0.33*time+uv.y*31.0)*0.0017;
+
+		color.r = texture2D(texture, vec2(x+uv.x+0.001,uv.y+0.001)).x+0.05;
+		color.g = texture2D(texture, vec2(x+uv.x+0.000,uv.y-0.002)).y+0.05;
+		color.b = texture2D(texture, vec2(x+uv.x-0.002,uv.y+0.000)).z+0.05;
+		color.r += 0.08*texture2D(texture, 0.75*vec2(x+0.025, -0.027)+vec2(uv.x+0.001,uv.y+0.001)).x;
+		color.g += 0.05*texture2D(texture, 0.75*vec2(x+-0.022, -0.02)+vec2(uv.x+0.000,uv.y-0.002)).y;
+		color.b += 0.08*texture2D(texture, 0.75*vec2(x+-0.02, -0.018)+vec2(uv.x-0.002,uv.y+0.000)).z;
+
+		color = clamp(color*0.6+0.4*color*color*1.0,0.0,1.0);
+
+		float vignette = (0.0 + 1.0*16.0*uv.x*uv.y*(1.0-uv.x)*(1.0-uv.y));
+		color *= vec3(pow(vignette, 0.25));
+
+		color *= vec3(0.95,1.05,0.95);
+		color *= 2.8;
+
+		float scanlines = clamp( 0.35+0.35*sin(3.5*time+uv.y*screen_size.y*1.5), 0.0, 1.0);
+		
+		float s = pow(scanlines,1.7);
+		color = color * vec3(0.4+0.7*s);
+
+		color *= 1.0+0.01*sin(110.0*time);
+		if (uv.x < 0.0 || uv.x > 1.0)
+			color *= 0.0;
+		if (uv.y < 0.0 || uv.y > 1.0)
+			color *= 0.0;
+		
+		color*=1.0-0.65*vec3(clamp((mod(gl_FragCoord.x, 2.0)-1.0)*2.0,0.0,1.0));
+		gl_FragColor = vec4(color,1.0);
+	}
+);
+
+typedef struct {
+	GLuint program;
+	GLuint vao;
+	struct {
+		GLuint projection;
+		GLuint screen_size;
+		GLuint time;
+	} uniform;
+	struct {
+		GLuint pos;
+		GLuint uv;
+	} attribute;
+} prg_post_t;
+
+void shader_post_general_init(prg_post_t *s) {
+	s->uniform.projection = glGetUniformLocation(s->program, "projection");
+	s->uniform.screen_size = glGetUniformLocation(s->program, "screen_size");
+	s->uniform.time = glGetUniformLocation(s->program, "time");
+
+	s->attribute.pos = glGetAttribLocation(s->program, "pos");
+	s->attribute.uv = glGetAttribLocation(s->program, "uv");
+
+	glGenVertexArrays(1, &s->vao);
+	glBindVertexArray(s->vao);
+
+	glEnableVertexAttribArray(s->attribute.pos);
+	glEnableVertexAttribArray(s->attribute.uv);
+
+	bind_va_f(s->attribute.pos, vertex_t, pos, 0);
+	bind_va_f(s->attribute.uv, vertex_t, uv, 0);
 }
 
+prg_post_t *shader_post_default_init() {
+	prg_post_t *s = mem_bump(sizeof(prg_post_t));
+	s->program = create_program(SHADER_POST_VS, SHADER_POST_FS_DEFAULT);	
+	shader_post_general_init(s);
+	return s;
+}
+
+prg_post_t *shader_post_crt_init() {
+	prg_post_t *s = mem_bump(sizeof(prg_post_t));
+	s->program = create_program(SHADER_POST_VS, SHADER_POST_FS_CRT);	
+	shader_post_general_init(s);
+	return s;
+}
+
+
+
+// -----------------------------------------------------------------------------
+
+static GLuint vbo;
+
+static tris_t tris_buffer[RENDER_TRIS_BUFFER_CAPACITY];
+static uint32_t tris_len = 0;
+
+static vec2i_t screen_size;
+static vec2i_t backbuffer_size;
+
+static uint32_t atlas_map[ATLAS_SIZE] = {0};
+static GLuint atlas_texture = 0;
+static render_blend_mode_t blend_mode = RENDER_BLEND_NORMAL;
+
+static mat4_t projection_mat_2d = mat4_identity();
+static mat4_t projection_mat_bb = mat4_identity();
+static mat4_t projection_mat_3d = mat4_identity();
+static mat4_t sprite_mat = mat4_identity();
+static mat4_t view_mat = mat4_identity();
+
+
+static render_texture_t textures[TEXTURES_MAX];
+static uint32_t textures_len = 0;
+static bool texture_mipmap_is_dirty = false;
+
+static render_resolution_t render_res;
+static GLuint backbuffer = 0;
+static GLuint backbuffer_texture = 0;
+static GLuint backbuffer_depth_buffer = 0;
+
+prg_game_t *prg_game;
+prg_post_t *prg_post;
+prg_post_t *prg_post_effects[NUM_RENDER_POST_EFFCTS] = {};
+
+
+static void render_flush();
+
+
 // static void gl_message_callback(GLenum source, GLenum type, GLuint id, GLenum severity, GLsizei length, const GLchar *message, const void *userParam) {
 // 	puts(message);
 // }
 
-void render_init(vec2i_t size) {	
+void render_init(vec2i_t screen_size) {	
 	#if defined(__APPLE__) && defined(__MACH__)
 		// OSX
 		// (nothing to do here)
@@ -213,52 +450,25 @@
 	uint32_t th = ATLAS_SIZE * ATLAS_GRID;
 	glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, tw, th, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL);
 	printf("atlas texture %5d\n", atlas_texture);
+	
 
-
-	// Shaders
-
-	GLuint fragment_shader = compile_shader(GL_FRAGMENT_SHADER, FRAGMENT_SHADER_YCRCB);
-	GLuint vertex_shader = compile_shader(GL_VERTEX_SHADER, VERTEX_SHADER);
-	GLuint shader_program = glCreateProgram();
-
-	glAttachShader(shader_program, vertex_shader);
-	glAttachShader(shader_program, fragment_shader);
-	glLinkProgram(shader_program);
-	glUseProgram(shader_program);
-
-	u_color = glGetUniformLocation(shader_program, "color");
-	u_view = glGetUniformLocation(shader_program, "view");
-	u_model = glGetUniformLocation(shader_program, "model");
-	u_projection = glGetUniformLocation(shader_program, "projection");
-	u_screen = glGetUniformLocation(shader_program, "screen");
-	u_camera_pos = glGetUniformLocation(shader_program, "camera_pos");
-	u_fade = glGetUniformLocation(shader_program, "fade");
-
-	a_pos = glGetAttribLocation(shader_program, "pos");
-	a_uv = glGetAttribLocation(shader_program, "uv");
-	a_color = glGetAttribLocation(shader_program, "color");
-
 	// Tris buffer
 
 	glGenBuffers(1, &vbo);
 	glBindBuffer(GL_ARRAY_BUFFER, vbo);
 
-	GLuint va;
-	glGenVertexArrays(1, &va);
-	glBindVertexArray(va);
 
+	// Post Shaders
 
-	// Defaults
+	prg_post_effects[RENDER_POST_NONE] = shader_post_default_init();
+	prg_post_effects[RENDER_POST_CRT] = shader_post_crt_init();
+	render_set_post_effect(RENDER_POST_NONE);
 
-	glEnableVertexAttribArray(a_pos);
-	glEnableVertexAttribArray(a_uv);
-	glEnableVertexAttribArray(a_color);
+	// Game shader
 
-	render_bind_va_f(a_pos, vertex_t, pos, 0);
-	render_bind_va_f(a_uv, vertex_t, uv, 0);
-	render_bind_va_color(a_color, vertex_t, color, 0);
+	prg_game = shader_game_init();
+	use_program(prg_game);
 
-	render_resize(size);
 	render_set_view(vec3(0, 0, 0), vec3(0, 0, 0));
 	render_set_model_mat(&mat4_identity());
 
@@ -274,6 +484,12 @@
 		rgba(128,128,128,255), rgba(128,128,128,255)
 	};
 	RENDER_NO_TEXTURE = render_texture_create(2, 2, white_pixels);
+
+
+	// Backbuffer
+
+	render_res = RENDER_RES_NATIVE;
+	render_set_screen_size(screen_size);
 }
 
 void render_cleanup() {
@@ -281,17 +497,17 @@
 }
 
 
-static void render_setup_2d_projection_mat() {
+static mat4_t render_setup_2d_projection_mat(vec2i_t size) {
 	float near = -1;
 	float far = 1;
 	float left = 0;
-	float right = screen_size.x;
-	float bottom = screen_size.y;
+	float right = size.x;
+	float bottom = size.y;
 	float top = 0;
 	float lr = 1 / (left - right);
 	float bt = 1 / (bottom - top);
 	float nf = 1 / (near - far);
-  	projection_mat_2d = mat4(
+	return mat4(
 		-2 * lr,  0,  0,  0,
 		0,  -2 * bt,  0,  0,
 		0,        0,  2 * nf,    0, 
@@ -299,16 +515,16 @@
 	);
 }
 
-static void render_setup_3d_projection_mat() {
+static mat4_t render_setup_3d_projection_mat(vec2i_t size) {
 	// wipeout has a horizontal fov of 90deg, but we want the fov to be fixed 
 	// for the vertical axis, so that widescreen displays just have a wider 
 	// view. For the original 4/3 aspect ratio this equates to a vertial fov
 	// of 73.75deg.
-	float aspect = (float)screen_size.x / (float)screen_size.y;
+	float aspect = (float)size.x / (float)size.y;
 	float fov = (73.75 / 180.0) * 3.14159265358;
 	float f = 1.0 / tan(fov / 2);
 	float nf = 1.0 / (NEAR_PLANE - FAR_PLANE);
-	projection_mat_3d = mat4(
+	return mat4(
 		f / aspect, 0, 0, 0,
 		0, f, 0, 0, 
 		0, 0, (FAR_PLANE + NEAR_PLANE) * nf, -1, 
@@ -316,20 +532,85 @@
 	);
 }
 
-void render_resize(vec2i_t size) {
-	glViewport(0, 0, size.x, size.y);
+void render_set_screen_size(vec2i_t size) {
 	screen_size = size;
+	projection_mat_bb = render_setup_2d_projection_mat(screen_size);
 
-	render_setup_2d_projection_mat();
-	render_setup_3d_projection_mat();
+	render_set_resolution(render_res);
 }
 
+
+void render_set_resolution(render_resolution_t res) {
+	render_res = res;
+
+	if (res == RENDER_RES_NATIVE) {
+		backbuffer_size = screen_size;
+	}
+	else {
+		float aspect = (float)screen_size.x / (float)screen_size.y;
+		if (res == RENDER_RES_240P) {
+			backbuffer_size = vec2i(240.0 * aspect, 240);
+		}
+		else if (res == RENDER_RES_480P) {
+			backbuffer_size = vec2i(480.0 * aspect, 480);	
+		}
+		else {
+			die("Invalid resolution: %d", res);
+		}
+	}
+
+	if (!backbuffer) {
+		glGenTextures(1, &backbuffer_texture);	
+		glGenFramebuffers(1, &backbuffer);
+		glGenRenderbuffers(1, &backbuffer_depth_buffer);
+	}
+	
+	glBindTexture(GL_TEXTURE_2D, backbuffer_texture);
+	glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, backbuffer_size.x, backbuffer_size.y, 0, GL_RGB, GL_UNSIGNED_BYTE, 0);
+	glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+	glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+	glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
+	glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
+	
+	glBindFramebuffer(GL_FRAMEBUFFER, backbuffer);
+	glBindRenderbuffer(GL_RENDERBUFFER, backbuffer_depth_buffer);	
+	glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, backbuffer_depth_buffer);
+	glFramebufferTexture(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, backbuffer_texture, 0);
+	
+	glBindRenderbuffer(GL_RENDERBUFFER, backbuffer_depth_buffer);
+	glRenderbufferStorage(GL_RENDERBUFFER, RENDER_DEPTH_BUFFER_INTERNAL_FORMAT, backbuffer_size.x, backbuffer_size.y);
+
+	projection_mat_2d = render_setup_2d_projection_mat(backbuffer_size);
+	projection_mat_3d = render_setup_3d_projection_mat(backbuffer_size);
+
+
+	// Use nearest texture min filter for 240p and 480p
+	glBindTexture(GL_TEXTURE_2D, atlas_texture);
+	if (res == RENDER_RES_NATIVE) {
+		glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, RENDER_USE_MIPMAPS ? GL_LINEAR_MIPMAP_LINEAR : GL_LINEAR);
+	}
+	else {
+		glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+	}
+	glViewport(0, 0, backbuffer_size.x, backbuffer_size.y);
+}
+
+void render_set_post_effect(render_post_effect_t post) {
+	error_if(post < 0 || post > NUM_RENDER_POST_EFFCTS, "Invalid post effect %d", post);
+	prg_post = prg_post_effects[post];
+}
+
 vec2i_t render_size() {
-	return screen_size;
+	return backbuffer_size;
 }
 
 void render_frame_prepare() {
-	glUniform2f(u_screen, 0, 0);
+	use_program(prg_game);
+	glBindFramebuffer(GL_FRAMEBUFFER, backbuffer);
+	glViewport(0, 0, backbuffer_size.x, backbuffer_size.y);
+
+	glBindTexture(GL_TEXTURE_2D, atlas_texture);
+	glUniform2f(prg_game->uniform.screen, 0, 0);
 	glEnable(GL_DEPTH_TEST);
 	glDepthMask(true);
 	glDisable(GL_POLYGON_OFFSET_FILL);
@@ -338,8 +619,38 @@
 	glEnable(GL_DEPTH_TEST); 
 }
 
-void render_frame_end() {	
+void render_frame_end() {
 	render_flush();
+
+	use_program(prg_post);
+
+	glBindFramebuffer(GL_FRAMEBUFFER, 0);
+	glViewport(0, 0, screen_size.x, screen_size.y);
+	glBindTexture(GL_TEXTURE_2D, backbuffer_texture);
+	glUniformMatrix4fv(prg_post->uniform.projection, 1, false, projection_mat_bb.m);
+	glUniform1f(prg_post->uniform.time, system_cycle_time());
+	glUniform2f(prg_post->uniform.screen_size, screen_size.x, screen_size.y);
+
+	glClearColor(0, 0, 0, 1);
+	glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
+
+	rgba_t white = rgba(128,128,128,255);
+	tris_buffer[tris_len++] = (tris_t){
+		.vertices = {
+			{.pos = {0, screen_size.y, 0}, .uv = {0, 0}, .color = white},
+			{.pos = {screen_size.x, 0, 0}, .uv = {1, 1}, .color = white},
+			{.pos = {0, 0, 0}, .uv = {0, 1}, .color = white},
+		}
+	};
+	tris_buffer[tris_len++] = (tris_t){
+		.vertices = {
+			{.pos = {screen_size.x, screen_size.y, 0}, .uv = {1, 0}, .color = white},
+			{.pos = {screen_size.x, 0, 0}, .uv = {1, 1}, .color = white},
+			{.pos = {0, screen_size.y, 0}, .uv = {0, 0}, .color = white},
+		}
+	};
+
+	render_flush();
 }
 
 void render_flush() {
@@ -373,10 +684,10 @@
 	render_set_model_mat(&mat4_identity());
 
 	render_flush();
-	glUniformMatrix4fv(u_view, 1, false, view_mat.m);
-	glUniformMatrix4fv(u_projection, 1, false, projection_mat_3d.m);
-	glUniform3f(u_camera_pos, pos.x, pos.y, pos.z);
-	glUniform2f(u_fade, RENDER_FADEOUT_NEAR, RENDER_FADEOUT_FAR);
+	glUniformMatrix4fv(prg_game->uniform.view, 1, false, view_mat.m);
+	glUniformMatrix4fv(prg_game->uniform.projection, 1, false, projection_mat_3d.m);
+	glUniform3f(prg_game->uniform.camera_pos, pos.x, pos.y, pos.z);
+	glUniform2f(prg_game->uniform.fade, RENDER_FADEOUT_NEAR, RENDER_FADEOUT_FAR);
 }
 
 void render_set_view_2d() {
@@ -385,14 +696,14 @@
 	render_set_depth_write(false);
 
 	render_set_model_mat(&mat4_identity());
-	glUniform3f(u_camera_pos, 0, 0, 0);
-	glUniformMatrix4fv(u_view, 1, false, mat4_identity().m);
-	glUniformMatrix4fv(u_projection, 1, false, projection_mat_2d.m);
+	glUniform3f(prg_game->uniform.camera_pos, 0, 0, 0);
+	glUniformMatrix4fv(prg_game->uniform.view, 1, false, mat4_identity().m);
+	glUniformMatrix4fv(prg_game->uniform.projection, 1, false, projection_mat_2d.m);
 }
 
 void render_set_model_mat(mat4_t *m) {
 	render_flush();
-	glUniformMatrix4fv(u_model, 1, false, m->m);
+	glUniformMatrix4fv(prg_game->uniform.model, 1, false, m->m);
 }
 
 void render_set_depth_write(bool enabled) {
@@ -423,7 +734,7 @@
 
 void render_set_screen_position(vec2_t pos) {
 	render_flush();
-	glUniform2f(u_screen, pos.x, -pos.y);
+	glUniform2f(prg_game->uniform.screen, pos.x, -pos.y);
 }
 
 void render_set_blend_mode(render_blend_mode_t new_mode) {
--- a/src/system.c
+++ b/src/system.c
@@ -57,7 +57,7 @@
 }
 
 void system_resize(vec2i_t size) {
-	render_resize(size);
+	render_set_screen_size(size);
 }
 
 double system_time_scale_get() {
--- a/src/wipeout/game.c
+++ b/src/wipeout/game.c
@@ -399,6 +399,8 @@
 	.ui_scale = 0,
 	.show_fps = false,
 	.fullscreen = false,
+	.screen_res = 0,
+	.post_effect = 0,
 
 	.has_rapier_class = true,  // for testing; should be false in prod
 	.has_bonus_circuts = true, // for testing; should be false in prod
@@ -488,6 +490,20 @@
 static void *global_mem_mark = 0;
 
 void game_init() {
+	if (file_exists("save.dat")) {
+		uint32_t size;
+		save_t *save_file = (save_t *)file_load("save.dat", &size);
+		if (size == sizeof(save_t) && save_file->magic == SAVE_DATA_MAGIC) {
+			printf("load save data success\n");
+			memcpy(&save, save_file, sizeof(save_t));
+		}
+		mem_temp_free(save_file);
+	}
+
+	platform_set_fullscreen(save.fullscreen);
+	render_set_resolution(save.screen_res);
+	render_set_post_effect(save.post_effect);
+
 	srand((int)(platform_now() * 100));
 	
 	ui_load();
@@ -574,16 +590,6 @@
 
 
 	game_set_scene(GAME_SCENE_INTRO);
-
-	if (file_exists("save.dat")) {
-		uint32_t size;
-		save_t *save_file = (save_t *)file_load("save.dat", &size);
-		if (size == sizeof(save_t) && save_file->magic == SAVE_DATA_MAGIC) {
-			printf("load save data success\n");
-			memcpy(&save, save_file, sizeof(save_t));
-		}
-		mem_temp_free(save_file);
-	}
 }
 
 void game_set_scene(game_scene_t scene) {
--- a/src/wipeout/game.h
+++ b/src/wipeout/game.h
@@ -242,6 +242,8 @@
 	uint8_t ui_scale;
 	bool show_fps;
 	bool fullscreen;
+	int screen_res;
+	int post_effect;
 
 	uint32_t has_rapier_class;
 	uint32_t has_bonus_circuts;
--- a/src/wipeout/main_menu.c
+++ b/src/wipeout/main_menu.c
@@ -156,8 +156,22 @@
 	save.is_dirty = true;
 }
 
+static void toggle_res(menu_t *menu, int data) {
+	render_set_resolution(data);
+	save.screen_res = data;
+	save.is_dirty = true;
+}
+
+static void toggle_post(menu_t *menu, int data) {
+	render_set_post_effect(data);
+	save.post_effect = data;
+	save.is_dirty = true;
+}
+
 static const char *opts_off_on[] = {"OFF", "ON"};
 static const char *opts_ui_sizes[] = {"AUTO", "1X", "2X", "3X", "4X"};
+static const char *opts_res[] = {"NATIVE", "240P", "480P"};
+static const char *opts_post[] = {"NONE", "CRT EFFECT"};
 
 static void page_options_video_init(menu_t *menu) {
 	menu_page_t *page = menu_push(menu, "VIDEO OPTIONS", NULL);
@@ -173,6 +187,8 @@
 	#endif
 	menu_page_add_toggle(page, save.ui_scale, "UI SCALE", opts_ui_sizes, len(opts_ui_sizes), toggle_ui_scale);
 	menu_page_add_toggle(page, save.show_fps, "SHOW FPS", opts_off_on, len(opts_off_on), toggle_show_fps);
+	menu_page_add_toggle(page, save.screen_res, "SCREEN RESOLUTION", opts_res, len(opts_res), toggle_res);
+	menu_page_add_toggle(page, save.post_effect, "POST PROCESSING", opts_post, len(opts_post), toggle_post);
 }
 
 // -----------------------------------------------------------------------------