use std::collections::HashMap;
use std::time::Instant;
use bevy::{
asset::{AssetId, RenderAssetUsages},
image::ImageSampler,
prelude::*,
render::{
ExtractSchedule, MainWorld, Render, RenderApp, RenderStartup, RenderSystems,
render_asset::RenderAssets,
render_resource::{
BindGroup, BindGroupEntry, BindGroupLayout, BindGroupLayoutEntry, BindingResource,
BindingType, BlendState, Buffer, BufferDescriptor, BufferUsages, ColorTargetState,
ColorWrites, CommandEncoderDescriptor, Extent3d, LoadOp, MultisampleState, Operations,
PipelineCompilationOptions, PipelineLayoutDescriptor, PrimitiveState, RawFragmentState,
RawRenderPipelineDescriptor, RawVertexBufferLayout, RawVertexState,
RenderPassColorAttachment, RenderPassDescriptor, RenderPipeline, SamplerBindingType,
ShaderModuleDescriptor, ShaderSource, ShaderStages, StoreOp, TextureDimension,
TextureFormat, TextureId, TextureSampleType, TextureUsages, TextureViewDimension,
VertexAttribute, VertexFormat, VertexStepMode,
},
renderer::{RenderDevice, RenderQueue},
texture::GpuImage,
},
text::{
ComputedTextBlock, FontAtlasSet, FontCx, FontHinting, LayoutCx, LetterSpacing, LineBreak,
LineHeight, ScaleCx, TextBounds, TextLayoutInfo, TextPipeline,
},
window::PrimaryWindow,
};
use super::{
PixelGeometry, ResolvedStyle, TerminalRenderConfig, TerminalRenderScale, TextRun,
block_geometry, cell_span, cursor_should_be_visible, line_glyph, push_block, push_line_glyph,
push_quadrants, quadrant_mask, text_font,
};
use crate::{TerminalSnapshot, TerminalSurface};
const TARGET_FORMAT: TextureFormat = TextureFormat::Rgba8Unorm;
const GLYPH_FORMAT: TextureFormat = TextureFormat::Rgba8UnormSrgb;
const GLYPH_ATLAS_SIZE: u32 = 2048;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum TerminalBatchPresentation {
#[default]
Ui,
Headless,
}
#[derive(Clone, Component)]
pub struct TerminalBatch {
surface: TerminalSurface,
config: TerminalRenderConfig,
presentation: TerminalBatchPresentation,
}
impl TerminalBatch {
#[must_use]
pub fn surface(&self) -> &TerminalSurface {
&self.surface
}
#[must_use]
pub fn renders_surface(&self, surface: &TerminalSurface) -> bool {
self.surface.shares_state_with(surface)
}
#[must_use]
pub const fn config(&self) -> &TerminalRenderConfig {
&self.config
}
#[must_use]
pub const fn config_mut(&mut self) -> &mut TerminalRenderConfig {
&mut self.config
}
#[must_use]
pub const fn presentation(&self) -> TerminalBatchPresentation {
self.presentation
}
}
#[derive(Clone, Debug, Component)]
pub struct TerminalBatchOutput {
pub image: Handle<Image>,
pub size: UVec2,
pub logical_size: Vec2,
pub raster_scale: f32,
}
#[derive(Component, Debug)]
pub struct TerminalBatchRoot {
pub terminal: Entity,
}
#[derive(Clone, Copy, Debug, Default, Component)]
pub struct TerminalBatchStats {
pub sync_frames: u64,
pub unchanged_frames: u64,
pub changed_rows: u32,
pub snapshot_cells: u32,
pub solid_quads: u32,
pub glyph_quads: u32,
pub draw_batches: u32,
pub extracted_bytes: u64,
pub cached_shapes: u32,
pub shape_misses: u32,
pub snapshot_ns: u64,
pub scene_ns: u64,
pub gpu_buffer_reallocations: u32,
pub gpu_write_calls: u32,
pub gpu_bytes_written: u64,
pub render_passes: u32,
pub draw_calls: u32,
pub pipeline_switches: u32,
pub atlas_bindings: u32,
}
pub struct BevyTerminalPlugin {
terminal: TerminalBatch,
}
impl BevyTerminalPlugin {
#[must_use]
pub fn new(surface: TerminalSurface) -> Self {
Self {
terminal: TerminalBatch {
surface,
config: TerminalRenderConfig::default(),
presentation: TerminalBatchPresentation::Ui,
},
}
}
#[must_use]
pub fn with_config(mut self, config: TerminalRenderConfig) -> Self {
self.terminal.config = config;
self
}
#[must_use]
pub fn headless(mut self) -> Self {
self.terminal.presentation = TerminalBatchPresentation::Headless;
self
}
#[must_use]
pub fn with_presentation(mut self, presentation: TerminalBatchPresentation) -> Self {
self.terminal.presentation = presentation;
self
}
}
impl Plugin for BevyTerminalPlugin {
fn build(&self, app: &mut App) {
if !app.is_plugin_added::<BatchRendererPlugin>() {
app.add_plugins(BatchRendererPlugin);
}
let terminal = self.terminal.clone();
let raster_scale =
resolve_raster_scale(terminal.config.render_scale, terminal.presentation, None);
let raster_config = physical_config(&terminal.config, raster_scale);
let logical_cell_size = raster_config.cell_size / raster_scale;
terminal
.surface
.set_cell_size(logical_cell_size.x, logical_cell_size.y);
let snapshot = terminal.surface.snapshot();
let size = terminal_pixel_size(&snapshot, &raster_config);
let output_image = make_target_image(size);
let output = app
.world_mut()
.resource_mut::<Assets<Image>>()
.add(output_image);
let glyph_atlas = {
let mut images = app.world_mut().resource_mut::<Assets<Image>>();
images.add(make_glyph_atlas_image())
};
let terminal_entity = app.world_mut().spawn_empty().id();
let ui_root = if terminal.presentation == TerminalBatchPresentation::Ui {
Some(
app.world_mut()
.spawn((
TerminalBatchRoot {
terminal: terminal_entity,
},
super::TerminalRoot,
ImageNode::new(output.clone()),
presentation_node(size, &terminal.config, raster_scale),
))
.id(),
)
} else {
None
};
app.world_mut().entity_mut(terminal_entity).insert((
terminal.clone(),
TerminalBatchOutput {
image: output.clone(),
size,
logical_size: size.as_vec2() / raster_scale,
raster_scale,
},
BatchMainState::new(
output,
glyph_atlas,
ui_root,
terminal.presentation,
raster_scale,
raster_config,
),
TerminalBatchStats::default(),
));
}
fn is_unique(&self) -> bool {
false
}
}
struct BatchRendererPlugin;
impl Plugin for BatchRendererPlugin {
fn build(&self, app: &mut App) {
app.add_systems(
Update,
sync_batch_terminals.in_set(super::TerminalSystems::Sync),
);
if let Some(render_app) = app.get_sub_app_mut(RenderApp) {
render_app
.init_resource::<PendingBatchScenes>()
.init_resource::<BatchGpuState>()
.add_systems(RenderStartup, reset_batch_gpu_state)
.add_systems(ExtractSchedule, extract_batch_scenes)
.add_systems(
Render,
render_batch_scenes
.run_if(batch_scenes_can_render_early)
.in_set(RenderSystems::ExtractCommands),
)
.add_systems(
Render,
render_batch_scenes.in_set(RenderSystems::PrepareMeshes),
);
}
}
}
fn terminal_pixel_size(snapshot: &TerminalSnapshot, config: &TerminalRenderConfig) -> UVec2 {
UVec2::new(
(f32::from(snapshot.size().width) * config.cell_size.x)
.round()
.max(1.0) as u32,
(f32::from(snapshot.size().height) * config.cell_size.y)
.round()
.max(1.0) as u32,
)
}
fn make_target_image(size: UVec2) -> Image {
let mut image = Image::new_uninit(
Extent3d {
width: size.x,
height: size.y,
depth_or_array_layers: 1,
},
TextureDimension::D2,
TARGET_FORMAT,
RenderAssetUsages::RENDER_WORLD,
);
image.texture_descriptor.usage =
TextureUsages::RENDER_ATTACHMENT | TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_SRC;
image.sampler = ImageSampler::nearest();
image
}
fn make_glyph_atlas_image() -> Image {
let mut image = Image::new_fill(
Extent3d {
width: GLYPH_ATLAS_SIZE,
height: GLYPH_ATLAS_SIZE,
depth_or_array_layers: 1,
},
TextureDimension::D2,
&[0, 0, 0, 0],
GLYPH_FORMAT,
RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD,
);
image.sampler = ImageSampler::nearest();
image
}
fn presentation_node(size: UVec2, config: &TerminalRenderConfig, raster_scale: f32) -> Node {
let origin = (config.origin * raster_scale).round() / raster_scale;
Node {
position_type: PositionType::Absolute,
left: px(origin.x),
top: px(origin.y),
width: px(size.x as f32 / raster_scale),
height: px(size.y as f32 / raster_scale),
overflow: Overflow::clip(),
..default()
}
}
fn resolve_raster_scale(
configured: TerminalRenderScale,
presentation: TerminalBatchPresentation,
window_scale: Option<f32>,
) -> f32 {
let requested = match configured {
TerminalRenderScale::Automatic if presentation == TerminalBatchPresentation::Ui => {
window_scale.unwrap_or(1.0)
}
TerminalRenderScale::Automatic => 1.0,
TerminalRenderScale::Fixed(scale) => scale,
};
if requested.is_finite() && requested > 0.0 {
requested.clamp(1.0, 8.0)
} else {
1.0
}
}
fn physical_config(config: &TerminalRenderConfig, raster_scale: f32) -> TerminalRenderConfig {
let mut physical = config.clone();
physical.cell_size = (config.cell_size * raster_scale).round().max(Vec2::ONE);
physical.font_size = (config.font_size * raster_scale).round().max(1.0);
physical.origin = Vec2::ZERO;
physical
}
#[derive(Clone)]
struct CachedGlyph {
texture: AssetId<Image>,
offset: Vec2,
size: Vec2,
uv: Vec4,
alpha_mask: bool,
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
struct SourceGlyph {
texture: AssetId<Image>,
x: u32,
y: u32,
width: u32,
height: u32,
}
struct UnifiedGlyphAtlas {
image: Handle<Image>,
glyphs: HashMap<SourceGlyph, Vec4>,
cursor: UVec2,
row_height: u32,
}
impl UnifiedGlyphAtlas {
fn new(image: Handle<Image>) -> Self {
Self {
image,
glyphs: HashMap::new(),
cursor: UVec2::splat(1),
row_height: 0,
}
}
fn cache(&mut self, source: SourceGlyph, images: &mut Assets<Image>) -> Option<Vec4> {
if let Some(uv) = self.glyphs.get(&source) {
return Some(*uv);
}
if source.width == 0 || source.height == 0 || source.width + 2 > GLYPH_ATLAS_SIZE {
return None;
}
let mut x = self.cursor.x;
let mut y = self.cursor.y;
if x + source.width + 1 > GLYPH_ATLAS_SIZE {
x = 1;
y = y.checked_add(self.row_height + 1)?;
self.row_height = 0;
}
if y + source.height + 1 > GLYPH_ATLAS_SIZE {
return None;
}
let pixels = {
let source_image = images.get(source.texture)?;
if source_image.texture_descriptor.format != GLYPH_FORMAT
|| source.x + source.width > source_image.width()
|| source.y + source.height > source_image.height()
{
return None;
}
let data = source_image.data.as_ref()?;
let source_stride = source_image.width() as usize * 4;
let row_bytes = source.width as usize * 4;
let mut pixels = Vec::with_capacity(row_bytes * source.height as usize);
for row in 0..source.height {
let start = (source.y + row) as usize * source_stride + source.x as usize * 4;
pixels.extend_from_slice(data.get(start..start + row_bytes)?);
}
pixels
};
let mut atlas = images.get_mut(&self.image)?;
let data = atlas.data.as_mut()?;
let atlas_stride = GLYPH_ATLAS_SIZE as usize * 4;
let row_bytes = source.width as usize * 4;
for row in 0..source.height {
let source_start = row as usize * row_bytes;
let target_start = (y + row) as usize * atlas_stride + x as usize * 4;
data[target_start..target_start + row_bytes]
.copy_from_slice(&pixels[source_start..source_start + row_bytes]);
}
self.cursor = UVec2::new(x + source.width + 1, y);
self.row_height = self.row_height.max(source.height);
let scale = GLYPH_ATLAS_SIZE as f32;
let uv = Vec4::new(
x as f32 / scale,
y as f32 / scale,
(x + source.width) as f32 / scale,
(y + source.height) as f32 / scale,
);
self.glyphs.insert(source, uv);
Some(uv)
}
fn clear(&mut self, images: &mut Assets<Image>) {
self.glyphs.clear();
self.cursor = UVec2::splat(1);
self.row_height = 0;
if let Some(mut image) = images.get_mut(&self.image)
&& let Some(data) = image.data.as_mut()
{
data.fill(0);
}
}
}
#[derive(Default)]
struct ShapeCaches {
normal: HashMap<String, Vec<CachedGlyph>>,
bold: HashMap<String, Vec<CachedGlyph>>,
italic: HashMap<String, Vec<CachedGlyph>>,
bold_italic: HashMap<String, Vec<CachedGlyph>>,
}
#[derive(Default)]
struct SceneScratch {
backgrounds: Vec<QuadInstance>,
foregrounds: Vec<QuadInstance>,
glyphs: Vec<(AssetId<Image>, QuadInstance)>,
decorations: Vec<QuadInstance>,
cursor: Vec<QuadInstance>,
styles: Vec<ResolvedStyle>,
}
impl SceneScratch {
fn clear(&mut self) {
self.backgrounds.clear();
self.foregrounds.clear();
self.glyphs.clear();
self.decorations.clear();
self.cursor.clear();
self.styles.clear();
}
}
impl ShapeCaches {
fn select(&self, style: &ResolvedStyle) -> &HashMap<String, Vec<CachedGlyph>> {
match (style.bold, style.italic) {
(false, false) => &self.normal,
(true, false) => &self.bold,
(false, true) => &self.italic,
(true, true) => &self.bold_italic,
}
}
fn select_mut(&mut self, style: &ResolvedStyle) -> &mut HashMap<String, Vec<CachedGlyph>> {
match (style.bold, style.italic) {
(false, false) => &mut self.normal,
(true, false) => &mut self.bold,
(false, true) => &mut self.italic,
(true, true) => &mut self.bold_italic,
}
}
fn len(&self) -> usize {
self.normal.len() + self.bold.len() + self.italic.len() + self.bold_italic.len()
}
fn clear(&mut self) {
self.normal.clear();
self.bold.clear();
self.italic.clear();
self.bold_italic.clear();
}
}
#[derive(Component)]
struct BatchMainState {
output: Handle<Image>,
ui_root: Option<Entity>,
presentation: TerminalBatchPresentation,
raster_scale: f32,
raster_config: TerminalRenderConfig,
last_snapshot: Option<TerminalSnapshot>,
pending: Option<BatchScene>,
shapes: ShapeCaches,
glyph_atlas: UnifiedGlyphAtlas,
scratch: SceneScratch,
vertex_capacity: usize,
blink: BlinkPhases,
}
impl BatchMainState {
fn new(
output: Handle<Image>,
glyph_atlas: Handle<Image>,
ui_root: Option<Entity>,
presentation: TerminalBatchPresentation,
raster_scale: f32,
raster_config: TerminalRenderConfig,
) -> Self {
Self {
output,
ui_root,
presentation,
raster_scale,
raster_config,
last_snapshot: None,
pending: None,
shapes: ShapeCaches::default(),
glyph_atlas: UnifiedGlyphAtlas::new(glyph_atlas),
scratch: SceneScratch::default(),
vertex_capacity: 0,
blink: BlinkPhases::default(),
}
}
}
#[derive(Clone, Copy)]
struct DrawBatch {
texture: AssetId<Image>,
start: u32,
count: u32,
}
#[derive(Clone, Copy)]
struct QuadInstance {
rect: Vec4,
uv: Vec4,
color: Vec4,
}
struct BatchScene {
destination: AssetId<Image>,
destination_size: UVec2,
instances: Vec<QuadInstance>,
batches: Vec<DrawBatch>,
clear: bool,
clear_color: Color,
requires_prepared_assets: bool,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
struct BlinkPhases {
slow_hidden: bool,
rapid_hidden: bool,
cursor_hidden: bool,
}
impl BlinkPhases {
fn at(elapsed: f32, config: &TerminalRenderConfig) -> Self {
Self {
slow_hidden: super::blink_hidden(elapsed, config.slow_blink_hz),
rapid_hidden: super::blink_hidden(elapsed, config.rapid_blink_hz),
cursor_hidden: config
.cursor_blink_hz
.is_some_and(|frequency| super::blink_hidden(elapsed, frequency)),
}
}
fn hides(self, style: &ResolvedStyle) -> bool {
(style.rapid_blink && self.rapid_hidden) || (style.slow_blink && self.slow_hidden)
}
}
#[derive(Resource, Default)]
struct PendingBatchScenes(Vec<BatchScene>);
#[allow(clippy::too_many_arguments)]
fn sync_batch_terminals(
mut terminals: Query<(
Ref<TerminalBatch>,
&mut BatchMainState,
&mut TerminalBatchOutput,
&mut TerminalBatchStats,
)>,
fonts: Res<Assets<Font>>,
mut images: ResMut<Assets<Image>>,
mut text_pipeline: ResMut<TextPipeline>,
mut font_atlas_set: ResMut<FontAtlasSet>,
mut font_cx: ResMut<FontCx>,
mut layout_cx: ResMut<LayoutCx>,
mut scale_cx: ResMut<ScaleCx>,
mut ui_nodes: Query<(&mut Node, &mut ImageNode), With<TerminalBatchRoot>>,
primary_window: Query<&Window, With<PrimaryWindow>>,
ui_scale: Res<UiScale>,
time: Option<Res<Time>>,
) {
let window_scale = primary_window
.iter()
.next()
.map(|window| window.scale_factor() * ui_scale.0);
let elapsed = time.as_ref().map_or(0.0, |time| time.elapsed_secs());
let fonts_changed = fonts.is_changed();
for (terminal, mut state, mut output, mut stats) in &mut terminals {
let terminal_changed = terminal.is_changed();
sync_batch_terminal(
&terminal,
terminal_changed,
&mut state,
&mut output,
&mut stats,
&fonts,
fonts_changed,
&mut images,
&mut text_pipeline,
&mut font_atlas_set,
&mut font_cx,
&mut layout_cx,
&mut scale_cx,
&mut ui_nodes,
window_scale,
elapsed,
);
}
}
#[allow(clippy::too_many_arguments)]
fn sync_batch_terminal(
terminal: &TerminalBatch,
terminal_changed: bool,
state: &mut BatchMainState,
output: &mut TerminalBatchOutput,
stats: &mut TerminalBatchStats,
fonts: &Assets<Font>,
fonts_changed: bool,
images: &mut Assets<Image>,
text_pipeline: &mut TextPipeline,
font_atlas_set: &mut FontAtlasSet,
font_cx: &mut FontCx,
layout_cx: &mut LayoutCx,
scale_cx: &mut ScaleCx,
ui_nodes: &mut Query<(&mut Node, &mut ImageNode), With<TerminalBatchRoot>>,
window_scale: Option<f32>,
elapsed: f32,
) {
let surface = &terminal.surface;
let config = &terminal.config;
stats.sync_frames = stats.sync_frames.wrapping_add(1);
stats.changed_rows = 0;
stats.snapshot_cells = 0;
stats.solid_quads = 0;
stats.glyph_quads = 0;
stats.draw_batches = 0;
stats.extracted_bytes = 0;
stats.shape_misses = 0;
stats.snapshot_ns = 0;
stats.scene_ns = 0;
stats.gpu_buffer_reallocations = 0;
stats.gpu_write_calls = 0;
stats.gpu_bytes_written = 0;
stats.render_passes = 0;
stats.draw_calls = 0;
stats.pipeline_switches = 0;
stats.atlas_bindings = 0;
let raster_scale = resolve_raster_scale(config.render_scale, state.presentation, window_scale);
let scale_changed = state.raster_scale != raster_scale;
let text_assets_changed = terminal_changed || fonts_changed || scale_changed;
if terminal_changed || scale_changed {
state.raster_config = physical_config(config, raster_scale);
let logical_cell_size = state.raster_config.cell_size / raster_scale;
surface.set_cell_size(logical_cell_size.x, logical_cell_size.y);
}
if text_assets_changed {
state.shapes.clear();
state.glyph_atlas.clear(images);
}
let blink = BlinkPhases::at(elapsed, config);
let blink_changed = blink != state.blink;
if state.last_snapshot.as_ref().is_some_and(|snapshot| {
snapshot.revision() == surface.revision() && !text_assets_changed && !blink_changed
}) {
stats.unchanged_frames = stats.unchanged_frames.wrapping_add(1);
stats.cached_shapes = u32::try_from(state.shapes.len()).unwrap_or(u32::MAX);
return;
}
let snapshot_start = Instant::now();
let (snapshot, changed_rows, mut full) = if let Some(mut snapshot) = state.last_snapshot.take()
{
let old_cursor = snapshot.cursor_position();
let update = surface.update_snapshot(&mut snapshot);
stats.snapshot_cells = u32::try_from(update.changed_cells).unwrap_or(u32::MAX);
let mut rows = update.changed_rows;
if update.cursor_position_changed || update.cursor_visibility_changed {
rows.push(old_cursor.y);
rows.push(snapshot.cursor_position().y);
rows.sort_unstable();
rows.dedup();
rows.retain(|row| *row < snapshot.size().height);
}
let full = update.resized || text_assets_changed;
if blink_changed && !full {
rows.extend(0..snapshot.size().height);
rows.sort_unstable();
rows.dedup();
}
(snapshot, rows, full)
} else {
let snapshot = surface.snapshot();
stats.snapshot_cells = u32::try_from(snapshot.cells().len()).unwrap_or(u32::MAX);
let rows = (0..snapshot.size().height).collect();
(snapshot, rows, true)
};
stats.snapshot_ns = snapshot_start
.elapsed()
.as_nanos()
.min(u128::from(u64::MAX)) as u64;
if changed_rows.is_empty() && !full && !blink_changed {
state.last_snapshot = Some(snapshot);
stats.unchanged_frames = stats.unchanged_frames.wrapping_add(1);
return;
}
full |= state.pending.is_some();
let new_size = terminal_pixel_size(&snapshot, &state.raster_config);
let output_resized = output.size != new_size;
if output_resized {
let resized = images.add(make_target_image(new_size));
state.output = resized.clone();
output.image = resized;
output.size = new_size;
}
output.logical_size = new_size.as_vec2() / raster_scale;
output.raster_scale = raster_scale;
if let Some(root) = state.ui_root
&& let Ok((mut node, mut image_node)) = ui_nodes.get_mut(root)
{
image_node.image = state.output.clone();
*node = presentation_node(new_size, config, raster_scale);
}
let rows: Vec<u16> = if full {
(0..snapshot.size().height).collect()
} else {
changed_rows
};
let scene_start = Instant::now();
let destination = state.output.id();
let BatchMainState {
raster_config,
shapes,
glyph_atlas,
scratch,
..
} = &mut *state;
let mut scene = build_scene(
&snapshot,
raster_config,
raster_scale,
&rows,
full,
destination,
fonts,
images,
text_pipeline,
font_atlas_set,
font_cx,
layout_cx,
scale_cx,
shapes,
glyph_atlas,
scratch,
stats,
blink,
);
scene.requires_prepared_assets = output_resized || stats.shape_misses != 0;
stats.scene_ns = scene_start.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64;
stats.changed_rows = u32::try_from(rows.len()).unwrap_or(u32::MAX);
stats.draw_batches = u32::try_from(scene.batches.len()).unwrap_or(u32::MAX);
stats.extracted_bytes = ((scene.instances.len() * std::mem::size_of::<QuadInstance>())
+ (scene.batches.len() * std::mem::size_of::<DrawBatch>()))
as u64;
let vertex_bytes = scene.instances.len() * 48;
stats.gpu_bytes_written = vertex_bytes as u64;
stats.gpu_write_calls = u32::from(vertex_bytes != 0);
stats.render_passes = 1;
stats.draw_calls = u32::try_from(scene.batches.len()).unwrap_or(u32::MAX);
stats.pipeline_switches = u32::from(!scene.batches.is_empty());
stats.atlas_bindings = stats.draw_calls;
if vertex_bytes > state.vertex_capacity {
state.vertex_capacity = vertex_bytes.next_power_of_two();
stats.gpu_buffer_reallocations = 1;
}
stats.cached_shapes = u32::try_from(state.shapes.len()).unwrap_or(u32::MAX);
state.pending = Some(scene);
state.last_snapshot = Some(snapshot);
state.blink = blink;
state.raster_scale = raster_scale;
}
#[allow(clippy::too_many_arguments)]
fn build_scene(
snapshot: &TerminalSnapshot,
config: &TerminalRenderConfig,
raster_scale: f32,
rows: &[u16],
full: bool,
destination: AssetId<Image>,
fonts: &Assets<Font>,
images: &mut Assets<Image>,
text_pipeline: &mut TextPipeline,
font_atlas_set: &mut FontAtlasSet,
font_cx: &mut FontCx,
layout_cx: &mut LayoutCx,
scale_cx: &mut ScaleCx,
shapes: &mut ShapeCaches,
glyph_atlas: &mut UnifiedGlyphAtlas,
scratch: &mut SceneScratch,
stats: &mut TerminalBatchStats,
blink: BlinkPhases,
) -> BatchScene {
let size = terminal_pixel_size(snapshot, config).as_vec2();
scratch.clear();
scratch.styles.reserve(usize::from(snapshot.size().width));
let SceneScratch {
backgrounds,
foregrounds,
glyphs,
decorations,
cursor,
styles,
} = scratch;
for &row in rows {
if !full {
backgrounds.push(solid_quad(
PixelGeometry {
x: 0.0,
y: f32::from(row) * config.cell_size.y,
width: size.x,
height: config.cell_size.y,
},
config.theme.background,
size,
));
}
let cells = snapshot.row(row);
styles.clear();
styles.extend(
cells
.iter()
.map(|cell| ResolvedStyle::new(cell, &config.theme)),
);
let mut background_start = 0;
while background_start < styles.len() {
if !cells[background_start].is_continuation()
&& procedural_cell_code(
cells[background_start].symbol(),
&styles[background_start],
blink,
raster_scale,
)
.is_some()
{
background_start += 1;
continue;
}
let color = styles[background_start].background;
let mut background_end = background_start + 1;
while background_end < styles.len()
&& styles[background_end].background == color
&& (cells[background_end].is_continuation()
|| procedural_cell_code(
cells[background_end].symbol(),
&styles[background_end],
blink,
raster_scale,
)
.is_none())
{
background_end += 1;
}
if full && color == config.theme.background {
background_start = background_end;
continue;
}
backgrounds.push(solid_quad(
PixelGeometry {
x: background_start as f32 * config.cell_size.x,
y: f32::from(row) * config.cell_size.y,
width: (background_end - background_start) as f32 * config.cell_size.x,
height: config.cell_size.y,
},
color,
size,
));
background_start = background_end;
}
let mut column = 0;
while column < cells.len() {
let cell = &cells[column];
if cell.is_continuation() {
column += 1;
continue;
}
let width = cell_span(cells, column);
let style = &styles[column];
let symbol = cell.symbol();
if style.hidden || blink.hides(style) {
column += width;
continue;
}
let mut exact = Vec::new();
let exact_run = || TextRun {
start: column as u16,
width: width as u16,
text: symbol.to_owned(),
style: style.clone(),
};
let procedural = procedural_cell_code(symbol, style, blink, raster_scale);
if let Some(code) = procedural {
foregrounds.push(procedural_cell_quad(
PixelGeometry {
x: column as f32 * config.cell_size.x,
y: f32::from(row) * config.cell_size.y,
width: config.cell_size.x,
height: config.cell_size.y,
},
style.foreground,
style.background,
code,
size,
));
} else if let Some(geometry) = block_geometry(symbol) {
push_block(&mut exact, &exact_run(), row, config, geometry, 1);
} else if let Some(mask) = quadrant_mask(symbol) {
push_quadrants(&mut exact, &exact_run(), row, config, mask, 1);
} else if let Some(glyph) = line_glyph(symbol) {
push_line_glyph(
&mut exact,
&exact_run(),
row,
config,
glyph,
1,
raster_scale.round().max(1.0),
);
} else if symbol != " " && !symbol.is_empty() {
let shaped = cached_shape(
symbol,
style,
config,
size,
fonts,
images,
text_pipeline,
font_atlas_set,
font_cx,
layout_cx,
scale_cx,
shapes,
glyph_atlas,
stats,
);
let anchor = Vec2::new(
column as f32 * config.cell_size.x,
f32::from(row) * config.cell_size.y,
);
let cell_bounds = PixelGeometry {
x: anchor.x,
y: anchor.y,
width: width as f32 * config.cell_size.x,
height: config.cell_size.y,
};
for glyph in shaped {
let geometry = PixelGeometry {
x: anchor.x + glyph.offset.x,
y: anchor.y + glyph.offset.y,
width: glyph.size.x,
height: glyph.size.y,
};
if let Some((geometry, uv)) =
clip_glyph_to_cell(geometry, glyph.uv, cell_bounds)
{
glyphs.push((
glyph.texture,
glyph_quad(geometry, uv, style.foreground, glyph.alpha_mask, size),
));
}
}
}
foregrounds.extend(
exact
.into_iter()
.map(|solid| solid_quad(solid.geometry, solid.color, size)),
);
let decoration_x = column as f32 * config.cell_size.x;
let decoration_width = width as f32 * config.cell_size.x;
let decoration_thickness = raster_scale.round().max(1.0);
if procedural.is_none() && style.underlined {
decorations.push(solid_quad(
PixelGeometry {
x: decoration_x,
y: f32::from(row) * config.cell_size.y
+ (config.cell_size.y - 2.0 * decoration_thickness).max(0.0),
width: decoration_width,
height: decoration_thickness,
},
style.underline,
size,
));
}
if procedural.is_none() && style.crossed_out {
decorations.push(solid_quad(
PixelGeometry {
x: decoration_x,
y: f32::from(row) * config.cell_size.y + config.cell_size.y * 0.55,
width: decoration_width,
height: decoration_thickness,
},
style.foreground,
size,
));
}
column += width;
}
}
if cursor_should_be_visible(snapshot)
&& !blink.cursor_hidden
&& (full || rows.contains(&snapshot.cursor_position().y))
{
let position = snapshot.cursor_position();
let cursor_thickness = raster_scale.round().max(1.0) * 2.0;
let (x, y, width, height) = match config.cursor_style {
super::CursorStyle::Block => (0.0, 0.0, config.cell_size.x, config.cell_size.y),
super::CursorStyle::Bar => (
0.0,
0.0,
cursor_thickness.min(config.cell_size.x),
config.cell_size.y,
),
super::CursorStyle::Underline => (
0.0,
(config.cell_size.y - cursor_thickness).max(0.0),
config.cell_size.x,
cursor_thickness.min(config.cell_size.y),
),
};
cursor.push(solid_quad(
PixelGeometry {
x: f32::from(position.x) * config.cell_size.x + x,
y: f32::from(position.y) * config.cell_size.y + y,
width,
height,
},
config.theme.cursor,
size,
));
}
stats.solid_quads =
u32::try_from(backgrounds.len() + foregrounds.len() + decorations.len() + cursor.len())
.unwrap_or(u32::MAX);
stats.glyph_quads = u32::try_from(glyphs.len()).unwrap_or(u32::MAX);
let mut instances = Vec::with_capacity(stats.solid_quads as usize + stats.glyph_quads as usize);
let mut batches = Vec::new();
let primary_atlas = glyph_atlas.image.id();
append_batch(&mut instances, &mut batches, primary_atlas, backgrounds);
append_batch(&mut instances, &mut batches, primary_atlas, foregrounds);
append_glyph_batches(&mut instances, &mut batches, glyphs);
append_batch(&mut instances, &mut batches, primary_atlas, decorations);
append_batch(&mut instances, &mut batches, primary_atlas, cursor);
BatchScene {
destination,
destination_size: size.as_uvec2(),
instances,
batches,
clear: full,
clear_color: config.theme.background,
requires_prepared_assets: false,
}
}
#[allow(clippy::too_many_arguments)]
fn cached_shape<'a>(
text: &str,
style: &ResolvedStyle,
config: &TerminalRenderConfig,
viewport: Vec2,
fonts: &Assets<Font>,
images: &mut Assets<Image>,
text_pipeline: &mut TextPipeline,
font_atlas_set: &mut FontAtlasSet,
font_cx: &mut FontCx,
layout_cx: &mut LayoutCx,
scale_cx: &mut ScaleCx,
shapes: &'a mut ShapeCaches,
glyph_atlas: &mut UnifiedGlyphAtlas,
stats: &mut TerminalBatchStats,
) -> &'a [CachedGlyph] {
if shapes.select(style).contains_key(text) {
return shapes
.select(style)
.get(text)
.expect("shape cache key was just found");
}
stats.shape_misses = stats.shape_misses.saturating_add(1);
let font = text_font(config, style);
let mut computed = ComputedTextBlock::default();
let mut layout = TextLayoutInfo::default();
let shape_result = text_pipeline.update_buffer(
fonts,
std::iter::once((
Entity::PLACEHOLDER,
0,
text,
&font,
Color::WHITE,
LineHeight::Px(config.cell_size.y),
LetterSpacing::default(),
)),
LineBreak::NoWrap,
Justify::Left,
TextBounds::UNBOUNDED,
1.0,
&mut computed,
font_cx,
layout_cx,
viewport,
20.0,
);
if shape_result.is_ok() {
let _ = text_pipeline.update_text_layout_info(
&mut layout,
font_atlas_set,
images,
&mut computed,
scale_cx,
TextBounds::UNBOUNDED,
Justify::Left,
FontHinting::Enabled,
);
}
let cached = layout
.glyphs
.into_iter()
.filter_map(|glyph| {
let atlas = images.get(glyph.atlas_info.texture)?;
let atlas_size = atlas.texture_descriptor.size;
let rect = glyph.atlas_info.rect;
let size = rect.size();
let source = SourceGlyph {
texture: glyph.atlas_info.texture,
x: rect.min.x as u32,
y: rect.min.y as u32,
width: size.x as u32,
height: size.y as u32,
};
let source_uv = Vec4::new(
rect.min.x / atlas_size.width as f32,
rect.min.y / atlas_size.height as f32,
rect.max.x / atlas_size.width as f32,
rect.max.y / atlas_size.height as f32,
);
let (texture, uv) = glyph_atlas
.cache(source, images)
.map_or((source.texture, source_uv), |uv| {
(glyph_atlas.image.id(), uv)
});
Some(CachedGlyph {
texture,
offset: (glyph.position - size * 0.5).round(),
size,
uv,
alpha_mask: glyph.atlas_info.is_alpha_mask,
})
})
.collect::<Vec<_>>();
shapes.select_mut(style).insert(text.to_owned(), cached);
shapes
.select(style)
.get(text)
.expect("newly shaped text was inserted")
}
fn solid_quad(geometry: PixelGeometry, color: Color, target: Vec2) -> QuadInstance {
QuadInstance {
rect: clip_rect(snap_geometry(geometry), target),
uv: Vec4::new(0.0, 0.0, 0.0, -1.0),
color: color.to_linear().to_f32_array().into(),
}
}
fn procedural_cell_code(
symbol: &str,
style: &ResolvedStyle,
blink: BlinkPhases,
raster_scale: f32,
) -> Option<u32> {
if style.hidden
|| blink.hides(style)
|| (style.underlined && style.underline != style.foreground)
{
return None;
}
let pattern = match symbol {
"█" => 0,
"▓" => 1,
"▒" => 2,
"░" => 3,
"▀" => 4,
"▄" => 5,
"▌" => 6,
"▐" => 7,
_ => return None,
};
let underline = u32::from(style.underlined) << 3;
let crossed = u32::from(style.crossed_out) << 4;
let pixel_scale = raster_scale.round().clamp(1.0, 15.0) as u32;
Some(pattern | underline | crossed | (pixel_scale << 5))
}
fn procedural_cell_quad(
geometry: PixelGeometry,
foreground: Color,
background: Color,
code: u32,
target: Vec2,
) -> QuadInstance {
let foreground = foreground.to_linear().to_f32_array();
let mut background = background.to_linear().to_f32_array();
background[3] = -(10.0 + code as f32);
QuadInstance {
rect: clip_rect(snap_geometry(geometry), target),
uv: foreground.into(),
color: background.into(),
}
}
fn glyph_quad(
geometry: PixelGeometry,
uv: Vec4,
color: Color,
alpha_mask: bool,
target: Vec2,
) -> QuadInstance {
let mut color = color.to_linear().to_f32_array();
if !alpha_mask {
color[3] = -1.0;
}
QuadInstance {
rect: clip_rect(snap_geometry(geometry), target),
uv,
color: color.into(),
}
}
fn clip_glyph_to_cell(
glyph: PixelGeometry,
uv: Vec4,
cell: PixelGeometry,
) -> Option<(PixelGeometry, Vec4)> {
if glyph.width <= 0.0 || glyph.height <= 0.0 || cell.width <= 0.0 || cell.height <= 0.0 {
return None;
}
let left = glyph.x.max(cell.x);
let top = glyph.y.max(cell.y);
let right = (glyph.x + glyph.width).min(cell.x + cell.width);
let bottom = (glyph.y + glyph.height).min(cell.y + cell.height);
if right <= left || bottom <= top {
return None;
}
let u_span = uv.z - uv.x;
let v_span = uv.w - uv.y;
let clipped_uv = Vec4::new(
((left - glyph.x) / glyph.width).mul_add(u_span, uv.x),
((top - glyph.y) / glyph.height).mul_add(v_span, uv.y),
((right - glyph.x) / glyph.width).mul_add(u_span, uv.x),
((bottom - glyph.y) / glyph.height).mul_add(v_span, uv.y),
);
Some((
PixelGeometry {
x: left,
y: top,
width: right - left,
height: bottom - top,
},
clipped_uv,
))
}
fn snap_geometry(geometry: PixelGeometry) -> PixelGeometry {
let left = geometry.x.round();
let top = geometry.y.round();
let right = (geometry.x + geometry.width).round().max(left);
let bottom = (geometry.y + geometry.height).round().max(top);
PixelGeometry {
x: left,
y: top,
width: right - left,
height: bottom - top,
}
}
fn clip_rect(geometry: PixelGeometry, target: Vec2) -> Vec4 {
let left = geometry.x / target.x * 2.0 - 1.0;
let right = (geometry.x + geometry.width) / target.x * 2.0 - 1.0;
let top = 1.0 - geometry.y / target.y * 2.0;
let bottom = 1.0 - (geometry.y + geometry.height) / target.y * 2.0;
Vec4::new(left, top, right, bottom)
}
fn append_batch(
instances: &mut Vec<QuadInstance>,
batches: &mut Vec<DrawBatch>,
texture: AssetId<Image>,
quads: &[QuadInstance],
) {
if quads.is_empty() {
return;
}
let start = instances.len() as u32;
let count = quads.len() as u32;
instances.extend_from_slice(quads);
if let Some(previous) = batches.last_mut()
&& previous.texture == texture
&& previous.start + previous.count == start
{
previous.count += count;
} else {
batches.push(DrawBatch {
texture,
start,
count,
});
}
}
fn append_glyph_batches(
instances: &mut Vec<QuadInstance>,
batches: &mut Vec<DrawBatch>,
glyphs: &[(AssetId<Image>, QuadInstance)],
) {
for &(texture, glyph) in glyphs {
append_batch(instances, batches, texture, std::slice::from_ref(&glyph));
}
}
fn extract_batch_scenes(
mut main_world: ResMut<MainWorld>,
mut pending: ResMut<PendingBatchScenes>,
) {
let mut terminals = main_world.query::<&mut BatchMainState>();
for mut state in terminals.iter_mut(&mut main_world) {
if let Some(scene) = state.pending.take() {
pending.0.push(scene);
}
}
}
fn batch_scenes_can_render_early(pending: Res<PendingBatchScenes>) -> bool {
!pending.0.is_empty()
&& pending
.0
.iter()
.all(|scene| !scene.requires_prepared_assets)
}
#[derive(Default, Resource)]
struct BatchGpuState {
vertex_buffer: Option<Buffer>,
vertex_capacity: u64,
texture_layout: Option<BindGroupLayout>,
pipeline: Option<RenderPipeline>,
texture_bind_groups: HashMap<AssetId<Image>, (TextureId, BindGroup)>,
}
impl BatchGpuState {
fn ensure_pipeline(&mut self, device: &RenderDevice) {
if self.pipeline.is_some() {
return;
}
let texture_layout = device.create_bind_group_layout(
"bevy_terminal batch texture layout",
&[
BindGroupLayoutEntry {
binding: 0,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Texture {
sample_type: TextureSampleType::Float { filterable: true },
view_dimension: TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
BindGroupLayoutEntry {
binding: 1,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Sampler(SamplerBindingType::Filtering),
count: None,
},
],
);
let pipeline = create_pipeline(
device,
&[&texture_layout],
"fragment",
BlendState::ALPHA_BLENDING,
);
self.texture_layout = Some(texture_layout);
self.pipeline = Some(pipeline);
}
}
fn reset_batch_gpu_state(mut gpu: ResMut<BatchGpuState>) {
*gpu = BatchGpuState::default();
}
fn create_pipeline(
device: &RenderDevice,
layouts: &[&BindGroupLayout],
fragment_entry: &'static str,
blend: BlendState,
) -> RenderPipeline {
let shader = device.create_and_validate_shader_module(ShaderModuleDescriptor {
label: Some("bevy_terminal batch shader"),
source: ShaderSource::Wgsl(BATCH_SHADER.into()),
});
let raw_layouts = layouts
.iter()
.map(|layout| Some(&***layout))
.collect::<Vec<_>>();
let layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
label: Some("bevy_terminal batch pipeline layout"),
bind_group_layouts: &raw_layouts,
immediate_size: 0,
});
let compilation = PipelineCompilationOptions::default();
const ATTRIBUTES: [VertexAttribute; 3] = [
VertexAttribute {
format: VertexFormat::Float32x4,
offset: 0,
shader_location: 0,
},
VertexAttribute {
format: VertexFormat::Float32x4,
offset: 16,
shader_location: 1,
},
VertexAttribute {
format: VertexFormat::Float32x4,
offset: 32,
shader_location: 2,
},
];
let vertex_buffers = [RawVertexBufferLayout {
array_stride: 48,
step_mode: VertexStepMode::Instance,
attributes: &ATTRIBUTES,
}];
device.create_render_pipeline(&RawRenderPipelineDescriptor {
label: Some("bevy_terminal batch pipeline"),
layout: Some(&layout),
vertex: RawVertexState {
module: &shader,
entry_point: Some("vertex"),
compilation_options: compilation.clone(),
buffers: &vertex_buffers,
},
fragment: Some(RawFragmentState {
module: &shader,
entry_point: Some(fragment_entry),
compilation_options: compilation,
targets: &[Some(ColorTargetState {
format: TARGET_FORMAT,
blend: Some(blend),
write_mask: ColorWrites::ALL,
})],
}),
primitive: PrimitiveState::default(),
depth_stencil: None,
multisample: MultisampleState::default(),
multiview_mask: None,
cache: None,
})
}
fn instance_bytes(instances: &[QuadInstance]) -> Vec<u8> {
let mut bytes = Vec::with_capacity(instances.len() * 48);
for instance in instances {
for value in instance
.rect
.to_array()
.into_iter()
.chain(instance.uv.to_array())
.chain(instance.color.to_array())
{
bytes.extend_from_slice(&value.to_ne_bytes());
}
}
bytes
}
fn render_batch_scenes(
mut pending: ResMut<PendingBatchScenes>,
mut gpu: ResMut<BatchGpuState>,
gpu_images: Res<RenderAssets<GpuImage>>,
device: Res<RenderDevice>,
queue: Res<RenderQueue>,
) {
let scenes = std::mem::take(&mut pending.0);
for scene in scenes {
let Some(target) = gpu_images.get(scene.destination) else {
pending.0.push(scene);
continue;
};
let target_size = target.texture_descriptor.size;
if target_size.width != scene.destination_size.x
|| target_size.height != scene.destination_size.y
{
pending.0.push(scene);
continue;
}
if scene
.batches
.iter()
.any(|batch| gpu_images.get(batch.texture).is_none())
{
pending.0.push(scene);
continue;
}
gpu.ensure_pipeline(&device);
if !scene.instances.is_empty() {
let bytes = instance_bytes(&scene.instances);
let required = bytes.len() as u64;
if required > gpu.vertex_capacity {
gpu.vertex_capacity = required.next_power_of_two();
gpu.vertex_buffer = Some(device.create_buffer(&BufferDescriptor {
label: Some("bevy_terminal terminal instances"),
size: gpu.vertex_capacity,
usage: BufferUsages::VERTEX | BufferUsages::COPY_DST,
mapped_at_creation: false,
}));
}
queue.write_buffer(
gpu.vertex_buffer
.as_ref()
.expect("non-empty instances allocate a vertex buffer"),
0,
&bytes,
);
}
for batch in &scene.batches {
let texture = batch.texture;
let image = gpu_images
.get(texture)
.expect("glyph readiness was checked before bind-group creation");
let texture_id = image.texture.id();
if gpu
.texture_bind_groups
.get(&texture)
.is_none_or(|(cached_id, _)| *cached_id != texture_id)
{
let bind_group = device.create_bind_group(
"bevy_terminal glyph atlas",
gpu.texture_layout
.as_ref()
.expect("pipeline initialization creates texture layout"),
&[
BindGroupEntry {
binding: 0,
resource: BindingResource::TextureView(&image.texture_view),
},
BindGroupEntry {
binding: 1,
resource: BindingResource::Sampler(&image.sampler),
},
],
);
gpu.texture_bind_groups
.insert(texture, (texture_id, bind_group));
}
}
let load = if scene.clear {
LoadOp::Clear(scene.clear_color.to_linear().into())
} else {
LoadOp::Load
};
let mut encoder = device.create_command_encoder(&CommandEncoderDescriptor {
label: Some("bevy_terminal terminal batch"),
});
let mut pass = encoder.begin_render_pass(&RenderPassDescriptor {
label: Some("bevy_terminal terminal batch"),
color_attachments: &[Some(RenderPassColorAttachment {
view: &target.texture_view,
depth_slice: None,
resolve_target: None,
ops: Operations {
load,
store: StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
if let Some(vertex_buffer) = &gpu.vertex_buffer {
pass.set_vertex_buffer(0, *vertex_buffer.slice(..));
pass.set_pipeline(gpu.pipeline.as_ref().expect("pipeline was initialized"));
for batch in &scene.batches {
pass.set_bind_group(
0,
gpu.texture_bind_groups
.get(&batch.texture)
.map(|(_, bind_group)| bind_group)
.expect("atlas bind group was prepared"),
&[],
);
pass.draw(0..6, batch.start..batch.start + batch.count);
}
}
drop(pass);
queue.submit([encoder.finish()]);
}
}
const BATCH_SHADER: &str = r#"
@group(0) @binding(0) var glyph_atlas: texture_2d<f32>;
@group(0) @binding(1) var glyph_sampler: sampler;
struct VertexInput {
@location(0) rect: vec4<f32>,
@location(1) uv: vec4<f32>,
@location(2) color: vec4<f32>,
}
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) color: vec4<f32>,
@location(2) @interpolate(flat) mode: i32,
@location(3) local: vec2<f32>,
@location(4) data: vec4<f32>,
}
@vertex
fn vertex(input: VertexInput, @builtin(vertex_index) index: u32) -> VertexOutput {
let corners = array<vec2<f32>, 6>(
vec2<f32>(0.0, 0.0), vec2<f32>(0.0, 1.0), vec2<f32>(1.0, 0.0),
vec2<f32>(1.0, 0.0), vec2<f32>(0.0, 1.0), vec2<f32>(1.0, 1.0),
);
let corner = corners[index % 6u];
var output: VertexOutput;
output.position = vec4<f32>(mix(input.rect.xy, input.rect.zw, corner), 0.0, 1.0);
output.uv = mix(input.uv.xy, input.uv.zw, corner);
output.color = input.color;
output.mode = select(0i, -1i, input.uv.w < 0.0);
if input.color.a <= -10.0 {
output.mode = i32(round(-input.color.a - 10.0)) + 1i;
}
output.local = corner;
output.data = input.uv;
return output;
}
@fragment
fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
if input.mode < 0i {
return input.color;
}
if input.mode > 0i {
let code = u32(input.mode - 1i);
let pattern = code & 7u;
let pixel_scale = max(code >> 5u, 1u);
let pixel = vec2<u32>(floor(input.position.xy)) / pixel_scale;
var foreground = false;
switch pattern {
case 0u: { foreground = true; }
case 1u: { foreground = !((pixel.x & 1u) == 0u && (pixel.y & 1u) == 0u); }
case 2u: { foreground = ((pixel.x + pixel.y) & 1u) == 0u; }
case 3u: { foreground = (pixel.x & 1u) == 0u && (pixel.y & 1u) == 0u; }
case 4u: { foreground = input.local.y < 0.5; }
case 5u: { foreground = input.local.y >= 0.5; }
case 6u: { foreground = input.local.x < 0.5; }
default: { foreground = input.local.x >= 0.5; }
}
let pixel_y = abs(dpdy(input.local.y)) * f32(pixel_scale);
if (code & 8u) != 0u
&& input.local.y >= 1.0 - 2.0 * pixel_y
&& input.local.y < 1.0 - pixel_y {
foreground = true;
}
if (code & 16u) != 0u
&& input.local.y >= 0.55
&& input.local.y < 0.55 + pixel_y {
foreground = true;
}
return select(vec4<f32>(input.color.rgb, 1.0), input.data, foreground);
}
let sample = textureSample(glyph_atlas, glyph_sampler, input.uv);
if input.color.a >= 0.0 {
return vec4<f32>(input.color.rgb, input.color.a * sample.a);
}
return sample;
}
"#;
#[cfg(test)]
mod tests {
use super::*;
use crate::GridSize;
fn quad(value: f32) -> QuadInstance {
QuadInstance {
rect: Vec4::splat(value),
uv: Vec4::ZERO,
color: Vec4::ONE,
}
}
#[test]
fn multiple_plugin_instances_own_distinct_surfaces_outputs_and_presenters() {
let first_surface = TerminalSurface::new(12, 4);
let second_surface = TerminalSurface::new(7, 9);
let mut app = App::new();
app.init_resource::<Assets<Image>>().add_plugins((
BevyTerminalPlugin::new(first_surface.clone()),
BevyTerminalPlugin::new(second_surface.clone()).headless(),
));
let mut terminals = app
.world_mut()
.query::<(Entity, &TerminalBatch, &TerminalBatchOutput)>();
let mut instances = terminals
.iter(app.world())
.map(|(entity, terminal, output)| {
(
entity,
terminal.surface().snapshot().size(),
output.image.id(),
output.size,
terminal.presentation(),
)
})
.collect::<Vec<_>>();
instances.sort_by_key(|(_, size, _, _, _)| size.width);
assert_eq!(instances.len(), 2);
assert_eq!(instances[0].1, GridSize::new(7, 9));
assert_eq!(instances[0].3, UVec2::new(77, 180));
assert_eq!(instances[0].4, TerminalBatchPresentation::Headless);
assert_eq!(instances[1].1, GridSize::new(12, 4));
assert_eq!(instances[1].3, UVec2::new(132, 80));
assert_eq!(instances[1].4, TerminalBatchPresentation::Ui);
assert_ne!(instances[0].2, instances[1].2);
let roots = app
.world_mut()
.query::<&TerminalBatchRoot>()
.iter(app.world())
.map(|root| root.terminal)
.collect::<Vec<_>>();
assert_eq!(roots, vec![instances[1].0]);
assert_eq!(first_surface.snapshot().size().width, 12);
assert_eq!(second_surface.snapshot().size().width, 7);
}
#[test]
fn pixel_rectangles_map_exactly_to_clip_space() {
assert_eq!(
clip_rect(
PixelGeometry {
x: 0.0,
y: 0.0,
width: 800.0,
height: 480.0,
},
Vec2::new(800.0, 480.0),
),
Vec4::new(-1.0, 1.0, 1.0, -1.0)
);
let cell = clip_rect(
PixelGeometry {
x: 400.0,
y: 240.0,
width: 10.0,
height: 20.0,
},
Vec2::new(800.0, 480.0),
);
assert!(cell.abs_diff_eq(Vec4::new(0.0, 0.0, 0.025, -1.0 / 12.0), 1e-6));
}
#[test]
fn terminal_target_uses_nearest_sampling() {
let image = make_target_image(UVec2::new(80, 40));
assert_eq!(image.sampler, ImageSampler::nearest());
}
#[test]
fn automatic_scale_tracks_ui_windows_but_not_headless_rendering() {
assert_eq!(
resolve_raster_scale(
TerminalRenderScale::Automatic,
TerminalBatchPresentation::Ui,
Some(2.0),
),
2.0
);
assert_eq!(
resolve_raster_scale(
TerminalRenderScale::Automatic,
TerminalBatchPresentation::Headless,
Some(2.0),
),
1.0
);
assert_eq!(
resolve_raster_scale(
TerminalRenderScale::Fixed(1.5),
TerminalBatchPresentation::Headless,
None,
),
1.5
);
assert_eq!(
resolve_raster_scale(
TerminalRenderScale::Fixed(f32::NAN),
TerminalBatchPresentation::Ui,
None,
),
1.0
);
}
#[test]
fn physical_metrics_and_geometry_are_pixel_aligned() {
let config = TerminalRenderConfig {
cell_size: Vec2::new(10.8, 19.6),
font_size: 17.6,
..default()
};
let physical = physical_config(&config, 2.0);
assert_eq!(physical.cell_size, Vec2::new(22.0, 39.0));
assert_eq!(physical.font_size, 35.0);
assert_eq!(
snap_geometry(PixelGeometry {
x: 4.5,
y: 9.5,
width: 1.0,
height: 2.0,
}),
PixelGeometry {
x: 5.0,
y: 10.0,
width: 1.0,
height: 2.0,
}
);
}
#[test]
fn fallback_glyph_bitmaps_are_clipped_to_their_terminal_cells() {
let clipped = clip_glyph_to_cell(
PixelGeometry {
x: -2.0,
y: 3.0,
width: 16.0,
height: 20.0,
},
Vec4::new(0.1, 0.2, 0.9, 0.8),
PixelGeometry {
x: 0.0,
y: 0.0,
width: 10.0,
height: 10.0,
},
)
.expect("the glyph overlaps the cell");
assert_eq!(
clipped.0,
PixelGeometry {
x: 0.0,
y: 3.0,
width: 10.0,
height: 7.0,
}
);
assert!(clipped.1.abs_diff_eq(Vec4::new(0.2, 0.2, 0.7, 0.41), 1e-6));
assert!(
clip_glyph_to_cell(
PixelGeometry {
x: 20.0,
y: 20.0,
width: 5.0,
height: 5.0,
},
Vec4::ONE,
PixelGeometry {
x: 0.0,
y: 0.0,
width: 10.0,
height: 10.0,
},
)
.is_none()
);
}
#[test]
fn glyph_batches_preserve_paint_order_and_coalesce_adjacent_atlases() {
let mut images = Assets::<Image>::default();
let atlas_a = images.add(Image::default()).id();
let atlas_b = images.add(Image::default()).id();
let glyphs = vec![
(atlas_a, quad(1.0)),
(atlas_a, quad(2.0)),
(atlas_b, quad(3.0)),
(atlas_a, quad(4.0)),
];
let mut instances = Vec::new();
let mut batches = Vec::new();
append_glyph_batches(&mut instances, &mut batches, &glyphs);
assert_eq!(instances.len(), 4);
assert_eq!(batches.len(), 3);
assert_eq!(batches[0].texture, atlas_a);
assert_eq!((batches[0].start, batches[0].count), (0, 2));
assert_eq!(batches[1].texture, atlas_b);
assert_eq!((batches[1].start, batches[1].count), (2, 1));
assert_eq!(batches[2].texture, atlas_a);
assert_eq!((batches[2].start, batches[2].count), (3, 1));
assert_eq!(instances[0].rect, Vec4::splat(1.0));
assert_eq!(instances[1].rect, Vec4::splat(2.0));
assert_eq!(instances[2].rect, Vec4::splat(3.0));
assert_eq!(instances[3].rect, Vec4::splat(4.0));
}
#[test]
fn replacement_batches_never_address_stale_capacity() {
let mut images = Assets::<Image>::default();
let atlas = images.add(Image::default()).id();
let mut instances = Vec::with_capacity(32);
let mut batches = Vec::new();
let first = vec![quad(1.0); 12];
append_batch(&mut instances, &mut batches, atlas, &first);
assert_eq!(batches[0].count, 12);
instances.clear();
batches.clear();
let second = vec![quad(2.0); 2];
append_batch(&mut instances, &mut batches, atlas, &second);
assert_eq!(instances.len(), 2);
assert_eq!((batches[0].start, batches[0].count), (0, 2));
}
#[test]
fn empty_scene_produces_no_upload_or_draw_batch() {
let mut images = Assets::<Image>::default();
let atlas = images.add(Image::default()).id();
let mut instances = Vec::new();
let mut batches = Vec::new();
append_batch(&mut instances, &mut batches, atlas, &[]);
append_glyph_batches(&mut instances, &mut batches, &[]);
assert!(instances.is_empty());
assert!(batches.is_empty());
assert!(instance_bytes(&instances).is_empty());
}
#[test]
fn hidden_blocks_fall_back_to_background_rendering() {
let mut cell = crate::TerminalCell::new("█");
cell.style.flags.insert(crate::StyleFlags::HIDDEN);
let style = ResolvedStyle::new(&cell, &super::super::TerminalTheme::default());
assert_eq!(
procedural_cell_code("█", &style, BlinkPhases::default(), 1.0),
None
);
}
#[test]
fn unified_atlas_copies_each_bevy_glyph_once_and_reuses_its_uv() {
let mut images = Assets::<Image>::default();
let mut source_pixels = vec![0; 4 * 4 * 4];
let source_offset = (4 + 1) * 4;
source_pixels[source_offset..source_offset + 4].copy_from_slice(&[11, 22, 33, 44]);
let source = images.add(Image::new(
Extent3d {
width: 4,
height: 4,
depth_or_array_layers: 1,
},
TextureDimension::D2,
source_pixels,
GLYPH_FORMAT,
RenderAssetUsages::MAIN_WORLD,
));
let target = images.add(make_glyph_atlas_image());
let mut atlas = UnifiedGlyphAtlas::new(target.clone());
let glyph = SourceGlyph {
texture: source.id(),
x: 1,
y: 1,
width: 1,
height: 1,
};
let first = atlas.cache(glyph, &mut images).expect("glyph should fit");
let cursor = atlas.cursor;
let second = atlas
.cache(glyph, &mut images)
.expect("glyph should be cached");
assert_eq!(first, second);
assert_eq!(atlas.cursor, cursor);
assert_eq!(atlas.glyphs.len(), 1);
let target_offset = (GLYPH_ATLAS_SIZE as usize + 1) * 4;
{
let target = images.get(&target).expect("target atlas exists");
assert_eq!(
&target.data.as_ref().expect("atlas has CPU data")
[target_offset..target_offset + 4],
&[11, 22, 33, 44]
);
}
atlas.clear(&mut images);
assert!(atlas.glyphs.is_empty());
assert_eq!(atlas.cursor, UVec2::splat(1));
assert_eq!(atlas.row_height, 0);
let target = images.get(&target).expect("target atlas exists");
assert_eq!(
&target.data.as_ref().expect("atlas has CPU data")[target_offset..target_offset + 4],
&[0, 0, 0, 0]
);
}
#[test]
fn blink_phases_follow_slow_rapid_and_disabled_cursor_rates() {
let mut config = TerminalRenderConfig {
slow_blink_hz: 1.0,
rapid_blink_hz: 2.0,
cursor_blink_hz: None,
..default()
};
let visible = BlinkPhases::at(0.1, &config);
assert!(!visible.slow_hidden && !visible.rapid_hidden && !visible.cursor_hidden);
let hidden = BlinkPhases::at(0.3, &config);
assert!(!hidden.slow_hidden && hidden.rapid_hidden && !hidden.cursor_hidden);
config.cursor_blink_hz = Some(1.0);
assert!(BlinkPhases::at(0.6, &config).cursor_hidden);
}
}