diff --git a/.gitmodules b/.gitmodules
index 481755f..e323a49 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -10,3 +10,6 @@
 [submodule "lib/WellspringCS"]
 	path = lib/WellspringCS
 	url = https://gitea.moonside.games/MoonsideGames/WellspringCS.git
+[submodule "lib/Theorafile"]
+	path = lib/Theorafile
+	url = https://github.com/FNA-XNA/Theorafile.git
diff --git a/MoonWorks.csproj b/MoonWorks.csproj
index 88105d4..31c1fce 100644
--- a/MoonWorks.csproj
+++ b/MoonWorks.csproj
@@ -15,6 +15,7 @@
 		
 		
 		
+		
 	
 
 	
@@ -22,4 +23,13 @@
 			PreserveNewest
 		
 	
+
+	
+		
+			MoonWorks.Shaders.FullscreenVert.spv
+		
+		
+			MoonWorks.Shaders.YUV2RGBAFrag.spv
+		
+	
 
diff --git a/MoonWorks.dll.config b/MoonWorks.dll.config
index d384162..c076485 100644
--- a/MoonWorks.dll.config
+++ b/MoonWorks.dll.config
@@ -15,4 +15,8 @@
 	
 	
 	
+
+	
+	
+	
 
diff --git a/lib/Theorafile b/lib/Theorafile
new file mode 160000
index 0000000..3ed1726
--- /dev/null
+++ b/lib/Theorafile
@@ -0,0 +1 @@
+Subproject commit 3ed1726b1e294799e85c3b597b114fb3b21cba72
diff --git a/src/Graphics/CommandBuffer.cs b/src/Graphics/CommandBuffer.cs
index bcd698a..598d50b 100644
--- a/src/Graphics/CommandBuffer.cs
+++ b/src/Graphics/CommandBuffer.cs
@@ -1,6 +1,4 @@
 using System;
-using System.Runtime.InteropServices;
-using MoonWorks.Math;
 using RefreshCS;
 
 namespace MoonWorks.Graphics
@@ -835,6 +833,26 @@ namespace MoonWorks.Graphics
 			SetTextureData(new TextureSlice(texture), dataPtr, dataLengthInBytes);
 		}
 
+		/// 
+		/// Asynchronously copies YUV data into three textures. Use with compressed video.
+		/// 
+		public void SetTextureDataYUV(Texture yTexture, Texture uTexture, Texture vTexture, IntPtr dataPtr, uint dataLengthInBytes)
+		{
+			Refresh.Refresh_SetTextureDataYUV(
+				Device.Handle,
+				Handle,
+				yTexture.Handle,
+				uTexture.Handle,
+				vTexture.Handle,
+				yTexture.Width,
+				yTexture.Height,
+				uTexture.Width,
+				uTexture.Height,
+				dataPtr,
+				dataLengthInBytes
+			);
+		}
+
 		/// 
 		/// Performs an asynchronous texture-to-texture copy on the GPU.
 		/// 
diff --git a/src/Graphics/GraphicsDevice.cs b/src/Graphics/GraphicsDevice.cs
index 99915b4..c111c9e 100644
--- a/src/Graphics/GraphicsDevice.cs
+++ b/src/Graphics/GraphicsDevice.cs
@@ -1,5 +1,6 @@
 using System;
 using System.Collections.Generic;
+using System.IO;
 using RefreshCS;
 
 namespace MoonWorks.Graphics
@@ -8,6 +9,11 @@ namespace MoonWorks.Graphics
 	{
 		public IntPtr Handle { get; }
 
+		// Built-in video pipeline
+		private ShaderModule VideoVertexShader { get; }
+		private ShaderModule VideoFragmentShader { get; }
+		internal GraphicsPipeline VideoPipeline { get; }
+
 		public bool IsDisposed { get; private set; }
 
 		private readonly List> resources = new List>();
@@ -28,6 +34,26 @@ namespace MoonWorks.Graphics
 				presentationParameters,
 				Conversions.BoolToByte(debugMode)
 			);
+
+			VideoVertexShader = new ShaderModule(this, GetEmbeddedResource("MoonWorks.Shaders.FullscreenVert.spv"));
+			VideoFragmentShader = new ShaderModule(this, GetEmbeddedResource("MoonWorks.Shaders.YUV2RGBAFrag.spv"));
+
+			VideoPipeline = new GraphicsPipeline(
+				this,
+				new GraphicsPipelineCreateInfo
+				{
+					AttachmentInfo = new GraphicsPipelineAttachmentInfo(
+						new ColorAttachmentDescription(TextureFormat.R8G8B8A8, ColorAttachmentBlendState.None)
+					),
+					DepthStencilState = DepthStencilState.Disable,
+					VertexShaderInfo = GraphicsShaderInfo.Create(VideoVertexShader, "main", 0),
+					FragmentShaderInfo = GraphicsShaderInfo.Create(VideoFragmentShader, "main", 3),
+					VertexInputState = VertexInputState.Empty,
+					RasterizerState = RasterizerState.CCW_CullNone,
+					PrimitiveType = PrimitiveType.TriangleList,
+					MultisampleState = MultisampleState.None
+				}
+			);
 		}
 
 		public CommandBuffer AcquireCommandBuffer()
@@ -77,6 +103,11 @@ namespace MoonWorks.Graphics
 			}
 		}
 
+		private static Stream GetEmbeddedResource(string name)
+		{
+			return typeof(GraphicsDevice).Assembly.GetManifestResourceStream(name);
+		}
+
 		protected virtual void Dispose(bool disposing)
 		{
 			if (!IsDisposed)
diff --git a/src/MoonWorksDllMap.cs b/src/MoonWorksDllMap.cs
index 5164838..fd9b0f3 100644
--- a/src/MoonWorksDllMap.cs
+++ b/src/MoonWorksDllMap.cs
@@ -196,6 +196,7 @@ namespace MoonWorks
 			NativeLibrary.SetDllImportResolver(typeof(RefreshCS.Refresh).Assembly, MapAndLoad);
 			NativeLibrary.SetDllImportResolver(typeof(FAudio).Assembly, MapAndLoad);
 			NativeLibrary.SetDllImportResolver(typeof(WellspringCS.Wellspring).Assembly, MapAndLoad);
+			NativeLibrary.SetDllImportResolver(typeof(Theorafile).Assembly, MapAndLoad);
 		}
 
 		#endregion
diff --git a/src/Video/Shaders/Compiled/FullscreenVert.spv b/src/Video/Shaders/Compiled/FullscreenVert.spv
new file mode 100644
index 0000000..ffc57de
Binary files /dev/null and b/src/Video/Shaders/Compiled/FullscreenVert.spv differ
diff --git a/src/Video/Shaders/Compiled/YUV2RGBAFrag.spv b/src/Video/Shaders/Compiled/YUV2RGBAFrag.spv
new file mode 100644
index 0000000..c9fbf32
Binary files /dev/null and b/src/Video/Shaders/Compiled/YUV2RGBAFrag.spv differ
diff --git a/src/Video/Shaders/Source/Fullscreen.vert b/src/Video/Shaders/Source/Fullscreen.vert
new file mode 100644
index 0000000..2f3c315
--- /dev/null
+++ b/src/Video/Shaders/Source/Fullscreen.vert
@@ -0,0 +1,9 @@
+#version 450
+
+layout(location = 0) out vec2 outTexCoord;
+
+void main()
+{
+	outTexCoord = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2);
+	gl_Position = vec4(outTexCoord * 2.0 - 1.0, 0.0, 1.0);
+}
diff --git a/src/Video/Shaders/Source/YUV2RGBA.frag b/src/Video/Shaders/Source/YUV2RGBA.frag
new file mode 100644
index 0000000..fe2b5a1
--- /dev/null
+++ b/src/Video/Shaders/Source/YUV2RGBA.frag
@@ -0,0 +1,38 @@
+/*
+ * This effect is based on the YUV-to-RGBA GLSL shader found in SDL.
+ * Thus, it also released under the zlib license:
+ * http://libsdl.org/license.php
+ */
+#version 450
+
+layout(location = 0) in vec2 TexCoord;
+
+layout(location = 0) out vec4 FragColor;
+
+layout(binding = 0, set = 1) uniform sampler2D YSampler;
+layout(binding = 1, set = 1) uniform sampler2D USampler;
+layout(binding = 2, set = 1) uniform sampler2D VSampler;
+
+/* More info about colorspace conversion:
+ * http://www.equasys.de/colorconversion.html
+ * http://www.equasys.de/colorformat.html
+ */
+
+const vec3 offset = vec3(-0.0625, -0.5, -0.5);
+const vec3 Rcoeff = vec3(1.164,  0.000,  1.793);
+const vec3 Gcoeff = vec3(1.164, -0.213, -0.533);
+const vec3 Bcoeff = vec3(1.164,  2.112,  0.000);
+
+void main()
+{
+	vec3 yuv;
+	yuv.x = texture(YSampler, TexCoord).r;
+	yuv.y = texture(USampler, TexCoord).r;
+	yuv.z = texture(VSampler, TexCoord).r;
+	yuv += offset;
+
+	FragColor.r = dot(yuv, Rcoeff);
+	FragColor.g = dot(yuv, Gcoeff);
+	FragColor.b = dot(yuv, Bcoeff);
+	FragColor.a = 1.0;
+}
diff --git a/src/Video/Video.cs b/src/Video/Video.cs
new file mode 100644
index 0000000..5dc4e55
--- /dev/null
+++ b/src/Video/Video.cs
@@ -0,0 +1,94 @@
+/* Heavily based on https://github.com/FNA-XNA/FNA/blob/master/src/Media/Xiph/Video.cs */
+using System;
+
+namespace MoonWorks.Video
+{
+	public class Video : IDisposable
+	{
+		public IntPtr Handle => handle;
+		public int YWidth => yWidth;
+		public int YHeight => yHeight;
+		public int UVWidth => uvWidth;
+		public int UVHeight => uvHeight;
+		public double FramesPerSecond => fps;
+
+		private IntPtr handle;
+		private int yWidth;
+		private int yHeight;
+		private int uvWidth;
+		private int uvHeight;
+		private double fps;
+
+		private bool disposed;
+
+		public Video(string filename)
+		{
+			Theorafile.th_pixel_fmt format;
+
+			if (!System.IO.File.Exists(filename))
+			{
+				throw new ArgumentException("Video file not found!");
+			}
+
+			if (Theorafile.tf_fopen(filename, out handle) < 0)
+			{
+				throw new ArgumentException("Invalid video file!");
+			}
+
+			Theorafile.tf_videoinfo(
+				handle,
+				out yWidth,
+				out yHeight,
+				out fps,
+				out format
+			);
+
+			if (format == Theorafile.th_pixel_fmt.TH_PF_420)
+			{
+				uvWidth = yWidth / 2;
+				uvHeight = yHeight / 2;
+			}
+			else if (format == Theorafile.th_pixel_fmt.TH_PF_422)
+			{
+				uvWidth = yWidth / 2;
+				uvHeight = yHeight;
+			}
+			else if (format == Theorafile.th_pixel_fmt.TH_PF_444)
+			{
+				uvWidth = yWidth;
+				uvHeight = yHeight;
+			}
+			else
+			{
+				throw new NotSupportedException("Unrecognized YUV format!");
+			}
+		}
+
+		protected virtual void Dispose(bool disposing)
+		{
+			if (!disposed)
+			{
+				if (disposing)
+				{
+					// TODO: dispose managed state (managed objects)
+				}
+
+				Theorafile.tf_close(ref handle);
+				disposed = true;
+			}
+		}
+
+		~Video()
+		{
+		    // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
+		    Dispose(disposing: false);
+		}
+
+		public void Dispose()
+		{
+			// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
+			Dispose(disposing: true);
+			GC.SuppressFinalize(this);
+		}
+	}
+}
diff --git a/src/Video/VideoPlayer.cs b/src/Video/VideoPlayer.cs
new file mode 100644
index 0000000..c8d275a
--- /dev/null
+++ b/src/Video/VideoPlayer.cs
@@ -0,0 +1,285 @@
+/* Heavily based on https://github.com/FNA-XNA/FNA/blob/master/src/Media/Xiph/VideoPlayer.cs */
+using System;
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using MoonWorks.Graphics;
+
+namespace MoonWorks.Video
+{
+	public enum VideoState
+	{
+		Playing,
+		Paused,
+		Stopped
+	}
+
+	public unsafe class VideoPlayer : IDisposable
+	{
+		public bool Loop { get; set; }
+		public bool Mute { get; set; }
+		public float Volume { get; set; }
+
+		private Video Video = null;
+		private VideoState State = VideoState.Stopped;
+
+		private void* yuvData = null;
+		private int yuvDataLength;
+		private int currentFrame;
+
+		private GraphicsDevice GraphicsDevice;
+		private Texture RenderTexture = null;
+		private Texture[] YUVTextures = new Texture[3];
+		private Sampler LinearSampler;
+
+		private Stopwatch timer;
+
+		private bool disposed;
+
+		public VideoPlayer(GraphicsDevice graphicsDevice)
+		{
+			GraphicsDevice = graphicsDevice;
+			timer = new Stopwatch();
+
+			LinearSampler = new Sampler(GraphicsDevice, SamplerCreateInfo.LinearClamp);
+		}
+
+		public void Load(Video video)
+		{
+			Video = video;
+			State = VideoState.Stopped;
+
+			if (yuvData != null)
+			{
+				NativeMemory.Free(yuvData);
+			}
+
+			yuvDataLength = (
+				(Video.YWidth * Video.YHeight) +
+				(Video.UVWidth * video.UVHeight * 2)
+			);
+
+			yuvData = NativeMemory.Alloc((nuint) yuvDataLength);
+
+			InitializeTheoraStream();
+
+			if (Theorafile.tf_hasvideo(Video.Handle) == 1)
+			{
+				if (RenderTexture != null)
+				{
+					RenderTexture.Dispose();
+				}
+
+				RenderTexture = Texture.CreateTexture2D(
+					GraphicsDevice,
+					(uint) Video.YWidth,
+					(uint) Video.YHeight,
+					TextureFormat.R8G8B8A8,
+					TextureUsageFlags.ColorTarget | TextureUsageFlags.Sampler
+				);
+
+				for (int i = 0; i < 3; i += 1)
+				{
+					if (YUVTextures[i] != null)
+					{
+						YUVTextures[i].Dispose();
+					}
+				}
+
+				YUVTextures[0] = Texture.CreateTexture2D(
+					GraphicsDevice,
+					(uint) Video.YWidth,
+					(uint) Video.YHeight,
+					TextureFormat.R8,
+					TextureUsageFlags.Sampler
+				);
+
+				YUVTextures[1] = Texture.CreateTexture2D(
+					GraphicsDevice,
+					(uint) Video.UVWidth,
+					(uint) Video.UVHeight,
+					TextureFormat.R8,
+					TextureUsageFlags.Sampler
+				);
+
+				YUVTextures[2] = Texture.CreateTexture2D(
+					GraphicsDevice,
+					(uint) Video.UVWidth,
+					(uint) Video.UVHeight,
+					TextureFormat.R8,
+					TextureUsageFlags.Sampler
+				);
+			}
+		}
+
+		public void Play(bool loop = false)
+		{
+			if (State == VideoState.Playing)
+			{
+				return;
+			}
+
+			Loop = loop;
+			timer.Start();
+
+			State = VideoState.Playing;
+		}
+
+		public void Pause()
+		{
+			if (State == VideoState.Paused)
+			{
+				return;
+			}
+
+			timer.Stop();
+
+			State = VideoState.Paused;
+		}
+
+		public void Stop()
+		{
+			if (State == VideoState.Stopped)
+			{
+				return;
+			}
+
+			timer.Stop();
+			timer.Reset();
+
+			Theorafile.tf_reset(Video.Handle);
+
+			State = VideoState.Stopped;
+		}
+
+		public Texture GetTexture()
+		{
+			if (Video == null)
+			{
+				throw new InvalidOperationException();
+			}
+
+			if (State == VideoState.Stopped || Video.Handle == IntPtr.Zero || Theorafile.tf_hasvideo(Video.Handle) == 0)
+			{
+				return RenderTexture;
+			}
+
+			int thisFrame = (int) (timer.Elapsed.TotalMilliseconds / (1000.0 / Video.FramesPerSecond));
+			if (thisFrame > currentFrame)
+			{
+				if (Theorafile.tf_readvideo(
+					Video.Handle,
+					(IntPtr) yuvData,
+					thisFrame - currentFrame
+				) == 1 || currentFrame == -1) {
+					UpdateTexture();
+				}
+
+				currentFrame = thisFrame;
+			}
+
+			bool ended = Theorafile.tf_eos(Video.Handle) == 1;
+			if (ended)
+			{
+				timer.Stop();
+				timer.Reset();
+
+				Theorafile.tf_reset(Video.Handle);
+
+				if (Loop)
+				{
+					// Start over!
+					InitializeTheoraStream();
+
+					timer.Start();
+
+				}
+				else
+				{
+					State = VideoState.Stopped;
+				}
+			}
+
+			return RenderTexture;
+		}
+
+		private void InitializeTheoraStream()
+		{
+			// Grab the first video frame ASAP.
+			while (Theorafile.tf_readvideo(Video.Handle, (IntPtr) yuvData, 1) == 0);
+
+			// Grab the first bit of audio. We're trying to start the decoding ASAP.
+			if (Theorafile.tf_hasaudio(Video.Handle) == 1)
+			{
+				int channels, samplerate;
+				Theorafile.tf_audioinfo(Video.Handle, out channels, out samplerate);
+
+				// TODO: audio stream
+			}
+
+			currentFrame = -1;
+		}
+
+		private void UpdateTexture()
+		{
+			var commandBuffer = GraphicsDevice.AcquireCommandBuffer();
+
+			commandBuffer.SetTextureDataYUV(
+				YUVTextures[0],
+				YUVTextures[1],
+				YUVTextures[2],
+				(IntPtr) yuvData,
+				(uint) yuvDataLength
+			);
+
+			commandBuffer.BeginRenderPass(
+				new ColorAttachmentInfo(RenderTexture, Color.Black)
+			);
+
+			commandBuffer.BindGraphicsPipeline(GraphicsDevice.VideoPipeline);
+			commandBuffer.BindFragmentSamplers(
+				new TextureSamplerBinding(YUVTextures[0], LinearSampler),
+				new TextureSamplerBinding(YUVTextures[1], LinearSampler),
+				new TextureSamplerBinding(YUVTextures[2], LinearSampler)
+			);
+
+			commandBuffer.DrawPrimitives(0, 1, 0, 0);
+
+			commandBuffer.EndRenderPass();
+
+			GraphicsDevice.Submit(commandBuffer);
+		}
+
+		protected virtual void Dispose(bool disposing)
+		{
+			if (!disposed)
+			{
+				if (disposing)
+				{
+					// dispose managed state (managed objects)
+					RenderTexture.Dispose();
+					YUVTextures[0].Dispose();
+					YUVTextures[1].Dispose();
+					YUVTextures[2].Dispose();
+				}
+
+				// free unmanaged resources (unmanaged objects) and override finalizer
+
+				NativeMemory.Free(yuvData);
+				disposed = true;
+			}
+		}
+
+		~VideoPlayer()
+		{
+		    // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
+		    Dispose(disposing: false);
+		}
+
+		public void Dispose()
+		{
+			// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
+			Dispose(disposing: true);
+			GC.SuppressFinalize(this);
+		}
+	}
+}