summary refs log tree commit diff
diff options
context:
space:
mode:
authorIrene Knapp <ireneista@irenes.space>2026-07-13 13:25:16 -0700
committerIrene Knapp <ireneista@irenes.space>2026-07-15 22:35:16 -0700
commit5dddb64f6a2fd072f2a26d4400f9a2a84386dc4e (patch)
treedb4e3b75cb5230f97f068b5e9455856cecba8807
parentec66f7419f2155d1b4bd852fdc3da87646d865c0 (diff)
it spins!!!!
ahem. more formally: provide animated uniforms to the shader, and use them

the tutorial just wants us to do three matrices, and to defer all the actual math to a library it recommends. that would certainly be simpler. anyway, we use quaterion+offset for everything but the projection frustum, and do all the math ourselves

Change-Id: I399f432bebb1e7ca4d4342efa1d5aaf37b32f427
Force-Push: yes
-rw-r--r--shaders/shader.vert86
-rw-r--r--src/graphics_permanent.rs40
-rw-r--r--src/graphics_window_dressing.rs177
-rw-r--r--src/linear_algebra.rs279
-rw-r--r--src/main.rs58
5 files changed, 611 insertions, 29 deletions
diff --git a/shaders/shader.vert b/shaders/shader.vert
index d53ef0a..ff1698d 100644
--- a/shaders/shader.vert
+++ b/shaders/shader.vert
@@ -1,7 +1,22 @@
 #version 460
 
+struct Transformation {
+  vec4 rotation;
+  vec3 translation;
+};
+
+// layout(binding = 0) uniform vec3 only_transform;
+
+layout(binding = 0) uniform UniformBlock {
+   vec3 scale;
+   Transformation model;
+   Transformation view;
+   mat4 projection;
+} uniform_block;
+
 layout(location = 0) in vec2 inPosition;
 layout(location = 1) in vec3 inColor;
+
 layout(location = 0) out vec3 vertexColor;
 
 vec2 triangle[3] = vec2[](
@@ -16,8 +31,77 @@ vec3 colors[3] = vec3[](
   vec3(0.0, 0.0, 1.0)
 );
 
+
+// /////////////////
+// // Quaternions //
+// /////////////////
+//
+//   To mathematicians, the components of a quaternion are traditionally
+// multiplied by 1, i, j, and k, where 1 is a real number and i, j, and k are
+// unit values along the three imaginary axes. GLSL doesn't give us component
+// names that map cleanly onto these, and xyzw are confusing in this context
+// because it's unclear whether x or w is the real-valued component, so we
+// reference the components by numeric index. Yes, that sucks, but everything
+// else sucks worse.
+//
+//   That is: 0 is the real value and 1, 2, and 3 are the i, j, and k values.
+//
+//   When reading code samples elsewhere, keep in mind that this is not the
+// only possible choice. Checking how components are labeled should be the
+// very first thing you do in understanding any quaternion arithmetic,
+// anywhere. Also keep in mind that there are many equivalent ways to write
+// any algebraic expression, and check carefully whether things are identical
+// in meaning.
+//
+//    This same challenge arises with matrices, it's just that the designers
+// of GLSL have chosen to encapsulate that complexity inside the language.
+
+//   This is kept carefully the same as quaternion_conjugate() in
+// linear_algebra.rs.
+vec4 quaternion_conjugate(vec4 a) {
+  return vec4(a[0], -a[1], -a[2], -a[3]);
+}
+
+//   This is kept carefully the same as quaternion_product() in
+// linear_algebra.rs.
+vec4 quaternion_product(vec4 a, vec4 b) {
+  return vec4((a[0] * b[0]) - (a[1] * b[1]) - (a[2] * b[2]) - (a[3] * b[3]),
+              (a[0] * b[1]) + (a[1] * b[0]) + (a[2] * b[3]) - (a[3] * b[2]),
+              (a[0] * b[2]) - (a[1] * b[3]) + (a[2] * b[0]) + (a[3] * b[1]),
+              (a[0] * b[3]) + (a[1] * b[2]) - (a[2] * b[1]) + (a[3] * b[0]));
+}
+
+vec3 quaternion_rotate(vec3 position, vec4 rotation) {
+  vec4 result = quaternion_product(quaternion_product(rotation,
+                                                      vec4(0.0, position)),
+                                   quaternion_conjugate(rotation));
+  return vec3(result[1], result[2], result[3]);
+}
+
+vec3 transform(vec3 a, Transformation transformation) {
+  vec3 rotated = quaternion_rotate(a, transformation.rotation);
+  vec3 translated = rotated + transformation.translation;
+  return translated;
+}
+
+
+vec4 apply_all_transforms(vec3 position) {
+  vec3 scaled = vec3(position.x * uniform_block.scale.x,
+                     position.y * uniform_block.scale.y,
+                     position.z * uniform_block.scale.z);
+  //return vec4(scaled, 1.0);
+  vec3 model = transform(scaled, uniform_block.model);
+  vec3 view = transform(model, uniform_block.view);
+  //vec4 projected = vec4(view, 1.0) * uniform_block.projection;
+  vec4 projected = vec4(view, 1.0) * uniform_block.projection;
+  //return vec4(view, 1.000);
+  return projected;
+  //return vec4(position, 1.0);
+}
+
+
 void main() {
-  gl_Position = vec4(inPosition, 0.0, 1.0);
+  gl_Position = apply_all_transforms(vec3(inPosition, 0.0));
   vertexColor = inColor;
 }
 
diff --git a/src/graphics_permanent.rs b/src/graphics_permanent.rs
index c2225a3..b58e8a4 100644
--- a/src/graphics_permanent.rs
+++ b/src/graphics_permanent.rs
@@ -127,11 +127,13 @@ impl PermanentGraphicsState {
         = init_vulkan_device(&instance, &surface,
                              enable_validation, enable_portability)?;
 
+    let descriptor_set_layout = init_descriptor_set_layout(&device)?;
+
     Ok((PermanentGraphicsState {
       window, entry, instance, debug_messager, surface, device,
-      graphics_queue, presentation_queue,
+      graphics_queue, presentation_queue
     }, GraphicsStateForReinit {
-      physical_device, indices
+      physical_device, indices, descriptor_set_layout
     }, enable_swapchain))
   }
 
@@ -214,6 +216,17 @@ impl PermanentGraphicsState {
 pub struct GraphicsStateForReinit {
   pub physical_device: vk::PhysicalDevice,
   pub indices: QueueFamilyIndices,
+  pub descriptor_set_layout: vk::DescriptorSetLayout,
+}
+
+
+impl GraphicsStateForReinit {
+  #[allow(unsafe_code)]
+  pub fn destroy(self, device: &Device) -> () {
+    unsafe {
+      device.destroy_descriptor_set_layout(self.descriptor_set_layout, None)
+    };
+  }
 }
 
 
@@ -517,6 +530,29 @@ fn init_vulkan_device(instance: &Instance, surface: &vk::SurfaceKHR,
 }
 
 
+#[allow(unsafe_code)]
+fn init_descriptor_set_layout(device: &Device)
+    -> Result<vk::DescriptorSetLayout>
+{
+  let uniform_block_binding = vk::DescriptorSetLayoutBinding::builder()
+          .binding(0)
+          .descriptor_type(vk::DescriptorType::UNIFORM_BUFFER)
+          .descriptor_count(1)
+          .stage_flags(vk::ShaderStageFlags::VERTEX);
+
+  let bindings = [uniform_block_binding];
+  let descriptor_set_layout_info
+          = vk::DescriptorSetLayoutCreateInfo::builder()
+                .bindings(&bindings);
+  let descriptor_set_layout = unsafe {
+    device.create_descriptor_set_layout(&descriptor_set_layout_info, None)
+  }?;
+
+  Ok(descriptor_set_layout)
+}
+
+
+
 //   To Vulkan, a "physical" device is the actual GPU, and a "logical"
 // device is per-process state that represents a connection to the GPU.
 // Before we can create a logical device, we must choose which physical
diff --git a/src/graphics_window_dressing.rs b/src/graphics_window_dressing.rs
index fb56fb3..38d4188 100644
--- a/src/graphics_window_dressing.rs
+++ b/src/graphics_window_dressing.rs
@@ -3,10 +3,10 @@ use crate::error::*;
 use crate::graphics_permanent::{
   PermanentGraphicsState, GraphicsStateForReinit, QueueFamilyIndices
 };
-use crate::linear_algebra::{ Vertex, VERTICES, INDICES };
+use crate::linear_algebra::{ Vertex, VERTICES, INDICES, UniformBlock };
 
 use std::collections::BTreeSet;
-use std::ptr;
+use std::ptr::copy_nonoverlapping;
 use vulkanalia::{ Device, Instance };
 use vulkanalia::vk::{ self, Handle, HasBuilder, InstanceV1_0, DeviceV1_0,
                       KhrSwapchainExtensionDeviceCommands };
@@ -43,6 +43,12 @@ pub struct WindowDressing {
   index_buffer: vk::Buffer,
   index_buffer_memory: vk::DeviceMemory,
 
+  uniform_buffers: Vec<vk::Buffer>,
+  pub uniform_buffer_memory: Vec<vk::DeviceMemory>,
+
+  descriptor_pool: vk::DescriptorPool,
+  descriptor_sets: Vec<vk::DescriptorSet>,
+
   pub command_buffers: Vec<vk::CommandBuffer>,
 
   pub concurrency: Concurrency,
@@ -58,7 +64,7 @@ pub struct Swapchain {
   images: Vec<vk::Image>,
   image_views: Vec<vk::ImageView>,
   format: vk::Format,
-  extent: vk::Extent2D,
+  pub extent: vk::Extent2D,
 }
 
 #[derive(Debug)]
@@ -95,6 +101,7 @@ impl WindowDressing {
     let graphics_queue = &permanent.graphics_queue;
     let physical_device = &for_reinit.physical_device;
     let indices = &for_reinit.indices;
+    let descriptor_set_layout = &for_reinit.descriptor_set_layout;
 
     let swapchain = init_swapchain(
             window, instance, surface, &physical_device, device, &indices)?;
@@ -102,7 +109,8 @@ impl WindowDressing {
     let render_pass = init_render_pass(device, &swapchain.format)?;
 
     let (pipeline_layout, pipeline)
-            = init_pipeline(device, &swapchain.extent, &render_pass)?;
+            = init_pipeline(device, descriptor_set_layout, &swapchain.extent,
+                            &render_pass)?;
 
     let framebuffers = init_framebuffers(
             device, &swapchain.extent, &swapchain.image_views, &render_pass)?;
@@ -116,12 +124,21 @@ impl WindowDressing {
     let (index_buffer, index_buffer_memory)
             = init_index_buffer(instance, physical_device, device,
                                 graphics_queue, &transient_command_pool)?;
+    let (uniform_buffers, uniform_buffer_memory)
+            = init_uniform_buffers(instance, physical_device, device,
+                                   swapchain.images.len())?;
+
+    let descriptor_pool
+            = init_descriptor_pool(device, swapchain.images.len())?;
+    let descriptor_sets
+            = init_descriptor_sets(device, descriptor_set_layout,
+                                   &uniform_buffers, &descriptor_pool,
+                                   swapchain.images.len())?;
 
-    let command_buffers = init_commands(device, &swapchain.extent,
-                                        &framebuffers, &render_pass,
-                                        &pipeline,
-                                        &vertex_buffer, &index_buffer,
-                                        &primary_command_pool)?;
+    let command_buffers = init_commands(
+            device, &swapchain.extent, &framebuffers, &render_pass,
+            &pipeline_layout, &pipeline, &vertex_buffer, &index_buffer,
+            &descriptor_sets, &primary_command_pool)?;
 
     let concurrency = init_concurrency(device, &swapchain.images)?;
 
@@ -135,6 +152,10 @@ impl WindowDressing {
       vertex_buffer_memory,
       index_buffer,
       index_buffer_memory,
+      uniform_buffers,
+      uniform_buffer_memory,
+      descriptor_pool,
+      descriptor_sets,
       primary_command_pool,
       transient_command_pool,
       command_buffers,
@@ -154,6 +175,7 @@ impl WindowDressing {
     let device = &permanent.device;
     let physical_device = &for_reinit.physical_device;
     let indices = &for_reinit.indices;
+    let descriptor_set_layout = &for_reinit.descriptor_set_layout;
 
     unsafe { device.device_wait_idle() }.unwrap();
 
@@ -165,15 +187,30 @@ impl WindowDressing {
     let render_pass = init_render_pass(device, &swapchain.format)?;
 
     let (pipeline_layout, pipeline)
-            = init_pipeline(device, &swapchain.extent, &render_pass)?;
+            = init_pipeline(device, descriptor_set_layout, &swapchain.extent,
+                            &render_pass)?;
 
     let framebuffers = init_framebuffers(
             device, &swapchain.extent, &swapchain.image_views, &render_pass)?;
 
+    let (uniform_buffers, uniform_buffer_memory)
+            = init_uniform_buffers(instance, physical_device, device,
+                                   swapchain.images.len())?;
+
+    // Notice that we did NOT reuse the descriptor pool.
+    let descriptor_pool
+            = init_descriptor_pool(device, swapchain.images.len())?;
+
+    let descriptor_sets
+            = init_descriptor_sets(device, descriptor_set_layout,
+                                   &uniform_buffers, &descriptor_pool,
+                                   swapchain.images.len())?;
+
     // Notice that we reused the command pool.
     let command_buffers = init_commands(
             device, &swapchain.extent, &framebuffers, &render_pass,
-            &pipeline, &self.vertex_buffer, &self.index_buffer,
+            &pipeline_layout, &pipeline, &self.vertex_buffer,
+            &self.index_buffer, &descriptor_sets,
             &self.primary_command_pool)?;
 
     self.concurrency.image_fences.resize(swapchain.images.len(),
@@ -184,6 +221,10 @@ impl WindowDressing {
     self.pipeline = pipeline;
     self.pipeline_layout = pipeline_layout;
     self.framebuffers = framebuffers;
+    self.uniform_buffers = uniform_buffers;
+    self.uniform_buffer_memory = uniform_buffer_memory;
+    self.descriptor_pool = descriptor_pool;
+    self.descriptor_sets = descriptor_sets;
     self.command_buffers = command_buffers;
 
     Ok(())
@@ -236,6 +277,20 @@ impl WindowDressing {
                                   &self.command_buffers)
     };
 
+    //   While the descriptor pool is also a pool, it has a preallocated size
+    // which will be different next time. So, we destroy it all the way.
+    unsafe { device.destroy_descriptor_pool(self.descriptor_pool, None) };
+
+    //   Notice that, unlike the vertex and index buffers, we destroy and
+    // re-create these on every reinitialization. That's because the number of
+    // them depends on how many images the swapchain has.
+    for buffer in &self.uniform_buffers {
+      unsafe { device.destroy_buffer(*buffer, None) };
+    }
+    for memory in &self.uniform_buffer_memory {
+      unsafe { device.free_memory(*memory, None) };
+    }
+
     unsafe { device.destroy_pipeline(self.pipeline, None) };
     unsafe { device.destroy_pipeline_layout(self.pipeline_layout, None) };
     unsafe { device.destroy_render_pass(self.render_pass, None) };
@@ -402,8 +457,9 @@ fn init_render_pass(device: &Device, format: &vk::Format)
 
 
 #[allow(unsafe_code)]
-fn init_pipeline(device: &Device, extent: &vk::Extent2D,
-                 render_pass: &vk::RenderPass)
+fn init_pipeline(device: &Device,
+                 descriptor_set_layout: &vk::DescriptorSetLayout,
+                 extent: &vk::Extent2D, render_pass: &vk::RenderPass)
     -> Result<(vk::PipelineLayout, vk::Pipeline)>
 {
   let vertex_binary = include_bytes!(
@@ -489,7 +545,9 @@ fn init_pipeline(device: &Device, extent: &vk::Extent2D,
                        .attachments(&blend_attachments)
                        .blend_constants([0.0, 0.0, 0.0, 0.0]);
 
-  let pipeline_layout_info = vk::PipelineLayoutCreateInfo::builder();
+  let layouts = [*descriptor_set_layout];
+  let pipeline_layout_info = vk::PipelineLayoutCreateInfo::builder()
+                                 .set_layouts(&layouts);
 
   let pipeline_layout = unsafe {
     device.create_pipeline_layout(&pipeline_layout_info, None)
@@ -571,6 +629,28 @@ fn init_index_buffer(instance: &Instance,
               vk::BufferUsageFlags::INDEX_BUFFER, INDICES)
 }
 
+fn init_uniform_buffers(instance: &Instance,
+                        physical_device: &vk::PhysicalDevice, device: &Device,
+                        count: usize)
+    -> Result<(Vec<vk::Buffer>, Vec<vk::DeviceMemory>)>
+{
+  let mut buffers = Vec::new();
+  let mut all_memory = Vec::new();
+
+  for _ in 0 .. count {
+    let (buffer, memory) = allocate_buffer(
+            instance, physical_device, device,
+            size_of::<UniformBlock<f32>>() as u64,
+            vk::BufferUsageFlags::UNIFORM_BUFFER,
+            vk::MemoryPropertyFlags::HOST_COHERENT
+            | vk::MemoryPropertyFlags::HOST_VISIBLE)?;
+    buffers.push(buffer);
+    all_memory.push(memory);
+  }
+
+  Ok((buffers, all_memory))
+}
+
 
 #[allow(unsafe_code)]
 fn init_buffer<T>(instance: &Instance,
@@ -593,8 +673,7 @@ fn init_buffer<T>(instance: &Instance,
   }?;
 
   unsafe {
-    ptr::copy_nonoverlapping(
-        contents.as_ptr(), host_memory.cast(), contents.len())
+    copy_nonoverlapping(contents.as_ptr(), host_memory.cast(), contents.len())
   };
 
   unsafe { device.unmap_memory(staging_memory) };
@@ -616,6 +695,61 @@ fn init_buffer<T>(instance: &Instance,
 
 
 #[allow(unsafe_code)]
+fn init_descriptor_pool(device: &Device, count: usize)
+    -> Result<vk::DescriptorPool>
+{
+  let uniform_block_size = vk::DescriptorPoolSize::builder()
+          .type_(vk::DescriptorType::UNIFORM_BUFFER)
+          .descriptor_count(count as u32);
+
+  let sizes = [uniform_block_size];
+  let pool_info = vk::DescriptorPoolCreateInfo::builder()
+          .pool_sizes(&sizes)
+          .max_sets(count as u32);
+  let pool = unsafe { device.create_descriptor_pool(&pool_info, None) }?;
+
+  Ok(pool)
+}
+
+
+#[allow(unsafe_code)]
+fn init_descriptor_sets(device: &Device, layout: &vk::DescriptorSetLayout,
+                        buffers: &Vec<vk::Buffer>, pool: &vk::DescriptorPool,
+                        count: usize)
+    -> Result<Vec<vk::DescriptorSet>>
+{
+  let layouts = vec![*layout; count];
+  let set_info = vk::DescriptorSetAllocateInfo::builder()
+          .descriptor_pool(*pool)
+          .set_layouts(&layouts);
+  let sets = unsafe { device.allocate_descriptor_sets(&set_info) }?;
+
+  for index in 0 .. count {
+    let buffer_info = vk::DescriptorBufferInfo::builder()
+            .buffer(buffers[index])
+            .offset(0)
+            .range(size_of::<UniformBlock<f32>>() as u64);
+
+    let buffer_info_list = [buffer_info];
+    let descriptor_write_info = vk::WriteDescriptorSet::builder()
+            .dst_set(sets[index])
+            .dst_binding(0)
+            .dst_array_element(0)
+            .descriptor_type(vk::DescriptorType::UNIFORM_BUFFER)
+            .buffer_info(&buffer_info_list);
+    let write_info_list = [descriptor_write_info];
+    let copy_info_list: [vk::CopyDescriptorSet; 0] = [];
+
+    unsafe {
+      device.update_descriptor_sets(&write_info_list, &copy_info_list)
+    };
+  }
+
+  Ok(sets)
+}
+
+
+#[allow(unsafe_code)]
 fn init_command_pools(device: &Device, indices: &QueueFamilyIndices)
     -> Result<(vk::CommandPool, vk::CommandPool)>
 {
@@ -641,9 +775,11 @@ fn init_commands(device: &Device,
                  extent: &vk::Extent2D,
                  framebuffers: &Vec<vk::Framebuffer>,
                  render_pass: &vk::RenderPass,
+                 pipeline_layout: &vk::PipelineLayout,
                  pipeline: &vk::Pipeline,
                  vertex_buffer: &vk::Buffer,
                  index_buffer: &vk::Buffer,
+                 descriptor_sets: &Vec<vk::DescriptorSet>,
                  command_pool: &vk::CommandPool)
     -> Result<Vec<vk::CommandBuffer>>
 {
@@ -708,6 +844,15 @@ fn init_commands(device: &Device,
     };
 
     unsafe {
+      device.cmd_bind_descriptor_sets(command_buffer,
+                                      vk::PipelineBindPoint::GRAPHICS,
+                                      *pipeline_layout,
+                                      0,
+                                      &[descriptor_sets[index]],
+                                      &[])
+    };
+
+    unsafe {
       device.cmd_draw_indexed(command_buffer, INDICES.len() as u32,
                               1, 0, 0, 0)
     };
diff --git a/src/linear_algebra.rs b/src/linear_algebra.rs
index 9b3ef73..26ce295 100644
--- a/src/linear_algebra.rs
+++ b/src/linear_algebra.rs
@@ -1,35 +1,294 @@
 #![forbid(unsafe_code)]
-use std::mem::size_of;
+use std::mem::{ size_of, MaybeUninit };
+use std::ops::{ Add, Sub, Mul, Div, Neg };
 use vulkanalia::vk::{ self, HasBuilder };
 
 
+pub trait Real: Add<Output=Self> + Sub<Output=Self>
+                + Mul<Output=Self> + Div<Output=Self>
+                + Neg<Output=Self>
+                + Sized
+                + From<f32>
+{
+  fn sin_cos(self) -> (Self, Self);
+  fn tan(self) -> Self;
+  fn recip(self) -> Self;
+}
+
+
+impl Real for f32 {
+  fn sin_cos(self) -> (Self, Self) { f32::sin_cos(self) }
+  fn tan(self) -> Self { f32::tan(self) }
+  fn recip(self) -> Self { f32::recip(self) }
+}
+
+
 pub static VERTICES: [Vertex<f32>; 4] = [
-  Vertex::new(Vec2(-0.5, -0.5), Vec3(1.0, 0.0, 0.0)),
-  Vertex::new(Vec2( 0.5, -0.5), Vec3(0.0, 1.0, 0.0)),
-  Vertex::new(Vec2( 0.5,  0.5), Vec3(0.0, 0.0, 1.0)),
-  Vertex::new(Vec2(-0.5,  0.5), Vec3(1.0, 1.0, 1.0)),
+  Vertex::new(Vec2::new(-0.5, -0.5), Vec3::new(1.0, 0.0, 0.0)),
+  Vertex::new(Vec2::new( 0.5, -0.5), Vec3::new(0.0, 1.0, 0.0)),
+  Vertex::new(Vec2::new( 0.5,  0.5), Vec3::new(0.0, 0.0, 1.0)),
+  Vertex::new(Vec2::new(-0.5,  0.5), Vec3::new(1.0, 1.0, 1.0)),
 ];
 
 pub const INDICES: &[u16] = &[0, 1, 2, 2, 3, 0];
 
 
+#[repr(C)]
 #[derive(Clone, Debug)]
-pub struct Vec2<T>(T, T);
+pub struct Vec2<T: Copy>([T; 2]);
+
+//   The GLSL spec version 4.60.8, in section 4.4, defers to the OpenGL spec
+// for the meaning of the "std140" qualifier. The OpenGL spec version 4.6
+// core profile, in section 7.6.2.2, states that the base alignment of a
+// 3-vector is the same as that for a 4-vector. It further states that, when
+// a structure contains another structure, the offset of the item
+// immediately following the sub-structure will be coerced to a multiple of
+// the alignment of the sub-structure. Nothing actually states that a vector
+// counts as a sub-structure for this purpose, and other nearby wording
+// makes a distinction between vectors and structures, but as-implemented,
+// it appears to.
+//
+//   So, we need padding.
+//
+//   That padding needs to be of the same size as T, and we want to do this
+// with the bare minimum restrictions on what T can be. Furthermore, our
+// constructors are const, and that's important to us. This may seem
+// impossible, but MaybeUninit comes to the rescue! It does require T to be
+// Copy; this is the reason that Copy is threaded everywhere in all these
+// types.
+#[repr(C)]
 #[derive(Clone, Debug)]
-pub struct Vec3<T>(T, T, T);
+pub struct Vec3<T: Copy>([T; 3], MaybeUninit<T>);
+
+#[repr(C)]
+#[derive(Clone, Debug)]
+#[allow(unused)]
+pub struct Vec4<T: Copy>([T; 4]);
+
+// All our matrices are column-major, which is what GLSL expects.
+#[repr(C)]
+#[derive(Clone, Debug)]
+#[allow(unused)]
+pub struct Mat2<T: Copy>([Vec2<T>; 2]);
+
+//   Using Vec3 here means we get its padding behavior, which is what we want,
+// per that same section of the OpenGL spec.
+#[repr(C)]
 #[derive(Clone, Debug)]
 #[allow(unused)]
-pub struct Vec4<T>(T, T, T, T);
+pub struct Mat3<T: Copy>([Vec3<T>; 3]);
+
+#[repr(C)]
+#[derive(Clone, Debug)]
+pub struct Mat4<T: Copy>([Vec4<T>; 4]);
+
+
+impl<T: Copy> Vec2<T> {
+  //   We use letters from the beginning of the alphabet for all of these, so
+  // as not to take a stance on any of the TODO finish this sentence
+  pub const fn new(a: T, b: T) -> Self {
+    Vec2([a, b])
+  }
+}
+
+impl<T: Copy> Vec3<T> {
+  pub const fn new(a: T, b: T, c: T) -> Self {
+    Vec3([a, b, c], MaybeUninit::zeroed())
+  }
+}
+
+impl<T: Copy> Vec4<T> {
+  pub const fn new(a: T, b: T, c: T, d: T) -> Self {
+    Vec4([a, b, c, d])
+  }
+
+  // This is kept carefully the same as quaternion_conjugate() in shader.vert.
+  #[allow(unused)]
+  pub fn quaternion_conjugate(&self) -> Self
+    where T: Real
+  {
+    Vec4::new(self.0[0], -self.0[1], -self.0[2], -self.0[3])
+  }
+
+  // This is kept carefully the same as quaternion_product() in shader.vert.
+  pub fn quaternion_product(&self, other: &Self) -> Self
+      where T: Real
+  {
+    let a = &self.0;
+    let b = &other.0;
+
+    Vec4::new((a[0] * b[0]) - (a[1] * b[1]) - (a[2] * b[2]) - (a[3] * b[3]),
+              (a[0] * b[1]) + (a[1] * b[0]) + (a[2] * b[3]) - (a[3] * b[2]),
+              (a[0] * b[2]) - (a[1] * b[3]) + (a[2] * b[0]) + (a[3] * b[1]),
+              (a[0] * b[3]) + (a[1] * b[2]) - (a[2] * b[1]) + (a[3] * b[0]))
+  }
+
+  //   The input angle is in radians. The resulting rotation is clockwise when
+  // looking in the positive direction of the axis, which is sometimes
+  // intuitive and sometimes not.
+  pub fn rotation_quaternion(axis: &Vec3<T>, angle: T) -> Self
+      where T: Real
+  {
+    let (sine, cosine) = (angle / 2.0.into()).sin_cos();
+
+    Self::new(cosine, sine * axis.0[0], sine * axis.0[1], sine * axis.0[2])
+  }
+
+  // A convenience method.
+  pub fn rotate(&self, axis: &Vec3<T>, angle: T) -> Self
+      where T: Real
+  {
+    //   Rotation quaternions are composed by multiplying them. The one on the
+    // left side of the product is applied second, and the one on the right
+    // side is applied first. So, we do this in the "reverse" order because
+    // we're notionally composing the new rotation after the one we already
+    // have.
+    Self::rotation_quaternion(axis, angle).quaternion_product(self)
+  }
+}
+
+impl<T: Copy> Mat2<T> {
+  #[allow(unused)]
+  pub const fn new(a: Vec2<T>, b: Vec2<T>) -> Self {
+    Mat2([a, b])
+  }
+}
+
+//   You may be tempted to reimplement Mat3<T> as a synonym for Vec3<Vec3<T>>.
+// This would be incorrect, because the padding behavior only applies at the
+// inner layer.
+impl<T: Copy> Mat3<T> {
+  #[allow(unused)]
+  pub const fn new(a: Vec3<T>, b: Vec3<T>, c: Vec3<T>) -> Self {
+    Mat3([a, b, c])
+  }
+}
+
+impl<T: Copy> Mat4<T> {
+  pub const fn new(a: Vec4<T>, b: Vec4<T>, c: Vec4<T>, d: Vec4<T>) -> Self {
+    Mat4([a, b, c, d])
+  }
+
+  //   This produces a 4x4 matrix suitable for use in a shader to transform
+  // coordinates. The matrix expects input in a Cartesian coordinate system
+  // where positive x is right, positive y is up, and positive z is deeper
+  // into the screen. Such a system is left-handed. The matrix's output
+  // coordinate system ranges from -1 to 1 in the x and y axes, and from 0 to
+  // 1 in the z axis, with the x and y axes being Euclidean and the z axis
+  // being homogenous - see below.
+  //
+  //   To apply the matrix, right-multiply with it. That is, compute a dot
+  // product where the right input is the matrix, and the left input is a
+  // position vector. The position must be a 4-vector with coordinates x, y,
+  // and z followed by a fourth value that is always 1.0. This fixed final
+  // coordinate of 1.0 allows the matrix to perform translation in addition to
+  // scaling and rotation.
+  //
+  //   The result of the multiplication is a 4-vector with coordinates x, y,
+  // z, and w, with the expectation that the other three coordinates will be
+  // divided by w as part of the final viewport transformation performed
+  // internally to the graphics API. This is necessary in order to produce the
+  // homogenous behavior of the z-coordinate; it also acts as a convenient way
+  // to scale the x and y coordinates by depth, implementing what artists call
+  // foreshortening.
+  //
+  //   The field of view is in radians, and is used as the angle measured
+  // vertically from top to bottom of the screen. The computed y scale will be
+  // directly based on this field of view, while the x scale will be based on
+  // the field of view and the aspect ratio. Thus, in setups where the screen
+  // is wider than it is tall, the horizontal field of view will be larger
+  // than the one given.
+  //
+  //   The final z value, after the matrix multiplication and w-division have
+  // both been applied, is homogenous rather than Cartesian: coordinate values
+  // designate points further apart in space the farther they get from the
+  // origin. This is important because floating-point numbers have limited
+  // precision, and we want to spend most of that precision on the most
+  // visually noticeable things, which are the closest ones. Anyway, graphics
+  // APIs don't actually give us a choice about that. :)
+  //
+  //   The near and far planes are used as the clipping boundaries for z
+  // values. An input position with z equal to the near value given here will
+  // have a final (multiplied and w-divided) z-value of 0.0; an input with z
+  // equal to the far value will have a final z of 1.0.
+  pub fn perspective(vertical_field_of_view: T, aspect_ratio: T,
+                     near: T, far: T)
+      -> Self
+      where T: Real
+  {
+    //   We compute some coefficients at the top here, which are best
+    // understood in the context of the full matrix, below.
+    let y_scale = (vertical_field_of_view / 2.0.into()).tan().recip();
+    let x_scale = y_scale / aspect_ratio;
+    let z_scale = far / (far - near);
+    let z_offset = -near * z_scale;
+
+    //   This matrix will be treated as being in column-major order. That is,
+    // each Vec4 becomes a column. In the shader, we will then right-multiply
+    // with it, meaning we'll compute the dot product of the point with the
+    // matrix in that order. The result of this is that the items in the
+    // first vector are summed to give the final "x", the second vector gives
+    // the final "y", and so on. It expects the input point to have a value of
+    // 1.0 for w (the final item), which allows using the final item of each
+    // Vec4 to represent a constant term.
+    Mat4::new(Vec4::new(x_scale, 0.0.into(), 0.0.into(), 0.0.into()),
+              Vec4::new(0.0.into(), y_scale, 0.0.into(), 0.0.into()),
+              Vec4::new(0.0.into(), 0.0.into(), z_scale, z_offset),
+              Vec4::new(0.0.into(), 0.0.into(), 1.0.into(), 0.0.into()))
+
+    //   These coefficients require significant explanation.
+    // Counterintuitively, z_offset is the one that ends up contributing the
+    // part that varies with the input z, though it is not multiplied by z
+    // during the matrix multiplication. Notice the 1.0 in the bottom row,
+    // which sets the output w equal to the input z. When the w-division later
+    // happens, the z factor that was multiplied with z_scale cancels out,
+    // while z_offset winds up being inversely proportionate to the input z.
+    //
+    //   Writing the full formula for the final z after matrix multiplication
+    // and w-division in terms of the input z, we get:
+    //
+    //   z_final = [far / (far-near)] - [ near*far / z*(far-near)]
+    //
+    //   Notice how both the constant part (from z_scale) and the part that
+    // varies with z (from z_offset) have (far - near) in the denominator.
+    // This is the part responsible for scaling the coordinates to the range
+    // 0.0 to 1.0. To see why z = near maps to z_final = 0.0 and z = far maps
+    // to z_final = 1.0, try substituting and simplifying.
+    //
+    //   Also consider the subexpression near*far / z. This is the way the
+    // non-linear behavior needed for a homogenous coordinate is produced.
+    // When z is at its minimum, this subexpression reduces to just "far";
+    // when z is at its maximum, the subexpression reduces to just "near".
+    // In between, the values are clustered densely around the "near" end. In
+    // the context of the full expression, this becomes the zero-to-one range.
+  }
+}
 
 
 #[repr(C)]
 #[derive(Clone, Debug)]
-pub struct Vertex<T> {
+pub struct Vertex<T: Copy> {
   pub position: Vec2<T>,
   pub color: Vec3<T>,
 }
 
-impl<T> Vertex<T> {
+#[repr(C)]
+#[derive(Clone, Debug)]
+pub struct Transformation<T: Copy> {
+  pub rotation: Vec4<T>,
+  pub translation: Vec3<T>,
+}
+
+#[repr(C)]
+#[derive(Clone, Debug)]
+pub struct UniformBlock<T: Copy> {
+  pub scale: Vec3<T>,
+  pub model: Transformation<T>,
+  pub view: Transformation<T>,
+  pub projection: Mat4<T>,
+}
+
+impl<T: Copy> Vertex<T> {
   const fn new(position: Vec2<T>, color: Vec3<T>) -> Self {
     Self { position, color }
   }
diff --git a/src/main.rs b/src/main.rs
index e6e3c0a..7372a24 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -6,8 +6,13 @@ use crate::graphics_permanent::{
 use crate::graphics_window_dressing::{
   WindowDressing, N_SIMULTANEOUS_FRAMES
 };
+use crate::linear_algebra::{ Vec3, Vec4, Mat4, Transformation, UniformBlock };
 
 use std::cell::RefCell;
+use std::f32::consts::{ FRAC_PI_2, FRAC_PI_4, TAU };
+use std::ptr::copy_nonoverlapping;
+use std::time::Instant;
+use vulkanalia::Device;
 use vulkanalia::vk::{ self, Handle, HasBuilder, DeviceV1_0,
                       KhrSwapchainExtensionDeviceCommands };
 use winit::application::ApplicationHandler;
@@ -28,6 +33,7 @@ struct Surreality {
   is_minimized: bool,
   is_reinit_queued: bool,
   frame_index: usize,
+  simulation_start: Instant,
 }
 
 impl Surreality {
@@ -39,6 +45,7 @@ impl Surreality {
       is_minimized: false,
       is_reinit_queued: false,
       frame_index: 0,
+      simulation_start: Instant::now(),
     }
   }
 
@@ -120,6 +127,11 @@ impl Surreality {
 
       concurrency.image_fences[image_index] = *frame_fence;
 
+      render_uniforms(device,
+                      &window_dressing.uniform_buffer_memory[image_index],
+                      &window_dressing.swapchain.extent,
+                      self.simulation_start)?;
+
       let first_semaphores = [*image_available_semaphore];
       let second_semaphores = [*rendering_finished_semaphore];
 
@@ -170,6 +182,10 @@ impl Drop for Surreality {
         window_dressing.destroy(&permanent.device);
       }
 
+      if let Some(for_reinit) = self.for_reinit.replace(None) {
+        for_reinit.destroy(&permanent.device);
+      }
+
       permanent.destroy();
     }
   }
@@ -217,6 +233,12 @@ impl ApplicationHandler for Surreality {
       _ => { }
     }
   }
+
+  fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
+    if let Some(permanent) = self.permanent.borrow().as_ref() {
+      permanent.window.request_redraw();
+    }
+  }
 }
 
 
@@ -237,3 +259,39 @@ fn main() -> std::process::ExitCode {
   }
 }
 
+
+#[allow(unsafe_code)]
+fn render_uniforms(device: &Device, device_memory: &vk::DeviceMemory,
+                   extent: &vk::Extent2D, simulation_start: Instant)
+    -> Result<()>
+{
+  let time = simulation_start.elapsed().as_secs_f32();
+
+  let scale = Vec3::new(1.0, 1.0, 1.0);
+  let model = Transformation {
+    rotation: Vec4::rotation_quaternion(&Vec3::new(-1.0, 0.0, 0.0), FRAC_PI_2)
+                  .rotate(&Vec3::new(0.0, 1.0, 0.0), time % TAU),
+    translation: Vec3::new(0.0, 0.0, 0.0),
+  };
+  let view = Transformation {
+    rotation: Vec4::rotation_quaternion(&Vec3::new(1.0, 0.0, 0.0), 0.1),
+    translation: Vec3::new(0.0, 0.0, 2.0),
+  };
+  let aspect_ratio = extent.width as f32 / extent.height as f32;
+  let projection = Mat4::perspective(FRAC_PI_4, aspect_ratio, 0.1, 10.0);
+  let block = UniformBlock::<f32> { scale, model, view, projection };
+
+  let size = size_of::<UniformBlock<f32>>() as u64;
+  let host_memory = unsafe {
+    device.map_memory(*device_memory, 0, size, vk::MemoryMapFlags::empty())
+  }?;
+
+  unsafe {
+    copy_nonoverlapping(&block, host_memory.cast(), 1)
+  };
+
+  unsafe { device.unmap_memory(*device_memory) };
+
+  Ok(())
+}
+