diff --git a/Cargo.lock b/Cargo.lock index 0f1a715c..b0f0a3ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -75,6 +75,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" + [[package]] name = "android-activity" version = "0.4.1" @@ -481,7 +487,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "907076dfda823b0b36d2a1bb5f90c96660a5bbcd7729e10727f07858f22c4edc" dependencies = [ "cfg-if", - "hashbrown", + "hashbrown 0.12.3", "lock_api", "once_cell", "parking_lot_core", @@ -595,6 +601,16 @@ dependencies = [ "miniz_oxide 0.7.1", ] +[[package]] +name = "fontdue" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0793f5137567643cf65ea42043a538804ff0fbf288649e2141442b602d81f9bc" +dependencies = [ + "hashbrown 0.13.2", + "ttf-parser 0.15.2", +] + [[package]] name = "foreign-types" version = "0.3.2" @@ -765,7 +781,7 @@ checksum = "0b0c02e1ba0bdb14e965058ca34e09c020f8e507a760df1121728e0aef68d57a" dependencies = [ "bitflags 1.3.2", "gpu-descriptor-types", - "hashbrown", + "hashbrown 0.12.3", ] [[package]] @@ -786,6 +802,25 @@ dependencies = [ "ahash 0.7.6", ] +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +dependencies = [ + "ahash 0.8.3", +] + +[[package]] +name = "hashbrown" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c6201b9ff9fd90a5a3bac2e56a830d0caa509576f0e503818ee82c181b3437a" +dependencies = [ + "ahash 0.8.3", + "allocator-api2", +] + [[package]] name = "hassle-rs" version = "0.10.0" @@ -865,7 +900,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", - "hashbrown", + "hashbrown 0.12.3", ] [[package]] @@ -1143,6 +1178,15 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "lru" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eedb2bdbad7e0634f83989bf596f497b070130daaa398ab22d84c39e266deec5" +dependencies = [ + "hashbrown 0.14.0", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -1489,8 +1533,10 @@ dependencies = [ "anyhow", "bytemuck", "env_logger", + "fontdue", "image", "log", + "lru", "notify", "oden-js", "pollster", @@ -1551,7 +1597,7 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "706de7e2214113d63a8238d1910463cfce781129a6f263d13fdb09ff64355ba4" dependencies = [ - "ttf-parser", + "ttf-parser 0.19.0", ] [[package]] @@ -2646,6 +2692,12 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "ttf-parser" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b3e06c9b9d80ed6b745c7159c40b311ad2916abb34a49e9be2653b90db0d8dd" + [[package]] name = "ttf-parser" version = "0.19.0" diff --git a/Cargo.toml b/Cargo.toml index 25912249..9c3d0ff3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,8 +12,10 @@ tracing = ["tracy-client/enable"] anyhow = "1.0" bytemuck = { version = "1.13", features = ["derive"] } env_logger = "0.10" +fontdue = "0.7.3" image = { version = "0.24", default-features = false, features = ["png"] } log = "0.4" +lru = "0.11.0" notify = "6" oden-js = { path = "oden-js" } pollster = "0.3" diff --git a/src/Inconsolata-Regular.ttf b/src/Inconsolata-Regular.ttf new file mode 100644 index 00000000..457d262c Binary files /dev/null and b/src/Inconsolata-Regular.ttf differ diff --git a/src/lib.rs b/src/lib.rs index 410dea38..2e86f274 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,6 +14,7 @@ use winit::{ mod script; use script::graphics::GraphicsCommand; +mod text; mod texture; #[repr(C)] @@ -144,6 +145,57 @@ impl CircleInstance { } } +#[repr(C)] +#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] +pub struct GlyphInstance { + // TODO: If it becomes important I can load the cell information onto the + // GPU in a texture or something and then just specify the top-left + // and index. + src_top_left: [f32; 2], + src_dims: [f32; 2], + + dest_top_left: [f32; 2], + dest_dims: [f32; 2], + + color: [f32; 4], +} + +impl GlyphInstance { + fn desc() -> wgpu::VertexBufferLayout<'static> { + wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as wgpu::BufferAddress, + step_mode: wgpu::VertexStepMode::Instance, + attributes: &[ + wgpu::VertexAttribute { + offset: 0, + shader_location: 5, + format: wgpu::VertexFormat::Float32x2, + }, + wgpu::VertexAttribute { + offset: std::mem::size_of::<[f32; 2]>() as wgpu::BufferAddress, + shader_location: 6, + format: wgpu::VertexFormat::Float32x2, + }, + wgpu::VertexAttribute { + offset: std::mem::size_of::<[f32; 4]>() as wgpu::BufferAddress, + shader_location: 7, + format: wgpu::VertexFormat::Float32x2, + }, + wgpu::VertexAttribute { + offset: std::mem::size_of::<[f32; 6]>() as wgpu::BufferAddress, + shader_location: 8, + format: wgpu::VertexFormat::Float32x2, + }, + wgpu::VertexAttribute { + offset: std::mem::size_of::<[f32; 8]>() as wgpu::BufferAddress, + shader_location: 9, + format: wgpu::VertexFormat::Float32x4, + }, + ], + } + } +} + #[repr(C)] #[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] struct ScreenUniforms { @@ -354,6 +406,12 @@ struct State { circle_pipeline: wgpu::RenderPipeline, circle_instance_buffers: VertexBufferPool, + text_pipeline: wgpu::RenderPipeline, + text_instance_buffers: VertexBufferPool, + + text_bind_group_layout: wgpu::BindGroupLayout, + fonts: HashMap, + write_textures: HashMap, screen_uniform: ScreenUniforms, @@ -453,6 +511,9 @@ impl State { label: Some("camera_bind_group"), }); + // ==================================================================== + // Sprites + // ==================================================================== let sprite_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("Sprite Shader"), source: wgpu::ShaderSource::Wgsl(include_str!("sprite_shader.wgsl").into()), @@ -478,6 +539,7 @@ impl State { entry_point: "fs_main", targets: &[Some(wgpu::ColorTargetState { format: config.format, + // TODO: This should be premultiplied alpha blending probably. blend: Some(wgpu::BlendState::ALPHA_BLENDING), write_mask: wgpu::ColorWrites::ALL, })], @@ -503,6 +565,9 @@ impl State { multiview: None, }); + // ==================================================================== + // Circles + // ==================================================================== let circle_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("Circle Shader"), source: wgpu::ShaderSource::Wgsl(include_str!("circle_shader.wgsl").into()), @@ -553,6 +618,81 @@ impl State { multiview: None, }); + // ==================================================================== + // Text + // ==================================================================== + let text_bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: true, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering), + count: None, + }, + ], + label: Some("text_bind_group_layout"), + }); + + let text_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("Text Shader"), + source: wgpu::ShaderSource::Wgsl(include_str!("text_shader.wgsl").into()), + }); + + let text_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Text Pipeline Layout"), + bind_group_layouts: &[&screen_uniform_bind_group_layout, &text_bind_group_layout], + push_constant_ranges: &[], + }); + + let text_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Text Pipeline"), + layout: Some(&text_pipeline_layout), + vertex: wgpu::VertexState { + module: &text_shader, + entry_point: "vs_main", + buffers: &[Vertex::desc(), GlyphInstance::desc()], + }, + fragment: Some(wgpu::FragmentState { + module: &text_shader, + entry_point: "fs_main", + targets: &[Some(wgpu::ColorTargetState { + format: config.format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: Some(wgpu::Face::Back), + // Setting this to anything other than Fill requires Features::NON_FILL_POLYGON_MODE + polygon_mode: wgpu::PolygonMode::Fill, + // Requires Features::DEPTH_CLIP_CONTROL + unclipped_depth: false, + // Requires Features::CONSERVATIVE_RASTERIZATION + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState { + count: 1, + mask: !0, + alpha_to_coverage_enabled: true, + }, + multiview: None, + }); + let sprite_vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("Sprite Vertex Buffer"), contents: bytemuck::cast_slice(SPRITE_VERTICES), @@ -577,6 +717,12 @@ impl State { circle_pipeline, circle_instance_buffers: VertexBufferPool::new(), + text_pipeline, + text_instance_buffers: VertexBufferPool::new(), + + text_bind_group_layout, + fonts: HashMap::new(), + write_textures: HashMap::new(), screen_uniform, screen_uniform_buffer, diff --git a/src/text.rs b/src/text.rs new file mode 100644 index 00000000..271c754c --- /dev/null +++ b/src/text.rs @@ -0,0 +1,214 @@ +use fontdue::Font; +use lru::LruCache; +use wgpu; + +/* +A note on how Casey Muratori's refterm works in terms of font rendering, +because it's not bad and handles things that we're not going to handle +because I just want to get some dang text on the dang screen right now. + +- Step 1: You break the text into runs of characters that need to be + rendered together. + +- Step 2: You figure out how many uniform cells this run occupies. + +- Step 3: For each cell, you figure out if you already have the necessary + part of the run in the texture atlas. (Right? Each part of the run has a + distinct ID based on the actual run and the cell within the run.) If you + don't have this [run,cell] in the atlas, then: + + - Step 3a: Render the run as a bitmap, if you haven't already. + - Step 3b: Get coordinates from the cache for this [run,cell] pair, + evicting something if necessary. + - Step 3c: Copy the part of the bitmap for this [run,cell] into the + texture atlas at the coordinates. + + Put the coordinates (either newly generated or pulled from the cache) into + the cell. + +- Step 4: Load all the cells onto the GPU and do a shader that renders the + cells. + +Specifically what I'm doing right now is going character-by-caracter, and not +doing runs and not handling things that are wider than a cell. I'm also not +doing the efficient big grid render because I want to render characters at +pixel offsets and kern between characters and whatnot, which is different +from what they're doing. Mine is almost certainly less efficient. + */ + +#[derive(Eq, PartialEq, Hash)] +enum CellCacheKey { + Garbage(u32), // Exists so that I can prime the LRU, not used generally. + GlyphIndex(u16), // Actual factual cache entries. +} + +pub struct FontCache { + font: Font, + size: f32, + atlas_width: u16, + atlas_height: u16, + char_width: u16, + char_height: u16, + texture: wgpu::Texture, + view: wgpu::TextureView, + + cells: Vec, + cell_cache: LruCache, +} + +enum SlotState { + Empty, + Allocated(u16, fontdue::Metrics), + Rendered(u16, fontdue::Metrics), +} + +struct GlyphCell { + // The coordinates in the atlas + x: u16, + y: u16, + + state: SlotState, +} + +impl FontCache { + fn new(device: &wgpu::Device, bytes: &[u8], size: f32) -> Self { + let font = fontdue::Font::from_bytes(bytes, fontdue::FontSettings::default()) + .expect("Could not parse font"); + + // Set up the texture that we'll use to cache the glyphs we're rendering. + let atlas_width = 2048; + let atlas_height = 2048; + + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Font Glyph Atlas"), + size: wgpu::Extent3d { + width: atlas_width.into(), + height: atlas_height.into(), + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 4, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::R8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + // Measure the font to figure out the size of a cell in the cache. + // NOTE: This metric nonsense is bad, probably. + let mut char_height = 0; + if let Some(line_metrics) = font.horizontal_line_metrics(size) { + char_height = (line_metrics.new_line_size + 0.5) as usize; + } + + let metrics = font.metrics('M', size); + let mut char_width = metrics.width; + char_height = metrics.height.max(char_height); + + let metrics = font.metrics('g', size); + char_width = metrics.width.max(char_width); + char_height = metrics.height.max(char_height); + + // Allocate the individual cells in the texture atlas; this records + // the state of what's in the cell and whatnot. + let mut cells = vec![]; + for y in (0..atlas_height).step_by(char_height) { + for x in (0..atlas_width).step_by(char_width) { + cells.push(GlyphCell { + x, + y, + state: SlotState::Empty, + }); + } + } + + // Allocate the LRU cache for the cells. Fill it with garbage so that + // we can always "allocate" by pulling from the LRU. + let mut cell_cache = LruCache::new(std::num::NonZeroUsize::new(cells.len()).unwrap()); + for i in 0..cells.len() { + cell_cache.put( + CellCacheKey::Garbage(i.try_into().expect("Too many cells!")), + i, + ); + } + + // Set up the binding group and pipeline and whatnot. + + FontCache { + font, + size, + atlas_width, + atlas_height, + char_width: char_width as u16, + char_height: char_height as u16, + texture, + view, + cells, + cell_cache, + } + } + + fn rasterize_character(&mut self, queue: &wgpu::Queue, index: u16, slot: &GlyphCell) { + let (metrics, bitmap) = self.font.rasterize_indexed(index, self.size); + + let mut texture = self.texture.as_image_copy(); + texture.origin.x = slot.x.into(); + texture.origin.y = slot.y.into(); + + queue.write_texture( + texture, + &bitmap, + wgpu::ImageDataLayout { + offset: 0, + bytes_per_row: Some(metrics.width as u32), + rows_per_image: None, + }, + wgpu::Extent3d { + width: metrics.width as u32, + height: metrics.height as u32, + depth_or_array_layers: 1, + }, + ); + } + + fn get_glyph_slot(&mut self, index: u16) -> &mut GlyphCell { + let key = CellCacheKey::GlyphIndex(index); + if let Some(cell_index) = self.cell_cache.get(&key) { + return &mut self.cells[*cell_index]; + } + + if let Some((_, cell_index)) = self.cell_cache.pop_lru() { + self.cell_cache.put(key, cell_index); + let cell = &mut self.cells[cell_index]; + cell.state = SlotState::Empty; + return cell; + } + + panic!("Did not put enough things in the LRU cache, why is it empty here?"); + } +} + +// pub fn inconsolata() { +// let font = include_bytes!("./Inconsolata-Regular.ttf") as &[u8]; +// let font = fontdue::Font::from_bytes(font, fontdue::FontSettings::default()).unwrap(); + +// let text = "Hello World!"; + +// // Break to characters. +// let size = 16.0; +// let mut prev = None; +// let mut left = 0.0; +// for c in text.chars() { +// // TODO: Cache this. +// let (metrics, bitmap) = font.rasterize(c, size); +// left += match prev { +// Some(pc) => match font.horizontal_kern(pc, c, size) { +// Some(k) => k, +// None => 0.0, +// }, +// None => 0.0, +// }; +// left += metrics.advance_width; +// } +// } diff --git a/src/text_shader.wgsl b/src/text_shader.wgsl new file mode 100644 index 00000000..2bd11680 --- /dev/null +++ b/src/text_shader.wgsl @@ -0,0 +1,85 @@ +// ---------------------------------------------------------------------------- +// Vertex shader +// ---------------------------------------------------------------------------- + +struct VertexInput { + @location(0) position : vec3, + @location(1) tex_coords : vec2, +}; + +struct InstanceInput { + @location(5) src_top_left: vec2, + @location(6) src_dims: vec2, + @location(7) dest_top_left: vec2, + @location(8) dest_dims: vec2, +}; + +struct VertexOutput { + @builtin(position) clip_position : vec4, + @location(0) tex_coords : vec2, +}; + +@vertex fn vs_main(vertex : VertexInput, instance : InstanceInput)->VertexOutput { + var out : VertexOutput; + out.tex_coords = instance.src_top_left + (vertex.tex_coords * instance.src_dims); + + let in_pos = instance.dest_top_left + (vec2f(vertex.position.x, vertex.position.y) * instance.dest_dims); + + let position = adjust_for_resolution(in_pos); + out.clip_position = vec4f(position.x, position.y, vertex.position.z, 1.0); + return out; +} + +// ---------------------------------------------------------------------------- +// Fragment shader +// ---------------------------------------------------------------------------- + +@group(1) @binding(0) var t_diffuse : texture_multisampled_2d; +@group(1) @binding(1) var s_diffuse : sampler; + +@fragment fn fs_main(in : VertexOutput)->@location(0) vec4 { + // TODO: Should we be sampling here for the shader? + let tc = vec2(u32(in.tex_coords.x), u32(in.tex_coords.y)); + return textureLoad(t_diffuse, tc, 0); +} + + +// ---------------------------------------------------------------------------- +// Resolution Handling +// ---------------------------------------------------------------------------- + +struct ScreenUniform { + resolution : vec2f, +}; +@group(0) @binding(0) // 1. + var screen : ScreenUniform; + +const RES = vec2f(320.0, 240.0); // The logical resolution of the screen. + +fn adjust_for_resolution(in_pos: vec2) -> vec2 { + // Adjust in_pos for the "resolution" of the screen. + let RES_AR = RES.x / RES.y; // The aspect ratio of the logical screen. + + // the actual resolution of the screen. + let screen_ar = screen.resolution.x / screen.resolution.y; + + // Compute the difference in resolution ... correctly? + // + // nudge is the amount to add to the logical resolution so that the pixels + // stay the same size but we respect the aspect ratio of the screen. (So + // there's more of them in either the x or y direction.) + var nudge = vec2f(0.0); + if (screen_ar > RES_AR) { + nudge.x = (RES.y * screen_ar) - RES.x; + } else { + nudge.y = (RES.x / screen_ar) - RES.y; + } + var new_logical_resolution = RES + nudge; + + // Now we can convert the incoming position to clip space, in the new screen. + let centered = in_pos + (nudge / 2.0); + var position = (2.0 * centered / new_logical_resolution) - 1.0; + position.y = -position.y; + + return position; +}