Replaces the bindless `binding_array<texture_2d<f32>>` the renderer bound every texture through. That array needs `VK_EXT_descriptor_indexing`, which a real share of Android GPUs lack, so the old shape did not run there at all. The two things being bound want opposite treatment, so they are now split: - **Glyph atlas pages become layers of one `texture_2d_array`.** A glyph primitive carries a `layer` instead of a view/sampler index pair. A layer index is an ordinary sampling operand, so this needs nothing beyond plain Vulkan 1.0 / GLES. Growing the atlas recreates the array with headroom and `copy_texture_to_texture`s the old layers across, no readback. - **A standalone image gets its own texture and its own bind group,** and draws in its own call. It no longer needs a per-instance entry in `PrimitiveData`: the bind group has already picked the texture. `Primitives` keeps images in a list of their own as a result, with `PrimitiveChange::is_image` naming which list a renumbering belongs to -- the two have independent index spaces, so `(layer, inst_idx)` alone would collide between them. Two notes on judgement calls, since this slice was rebuilt on top of `main` rather than transplanted: - The source version renamed `GlyphEntry::is_colored` to `is_color` and added a second `IS_COLOR` flag constant beside the existing `GlyphEntry::IS_COLORED`. Both dropped: #10's naming and its `flags()` are kept, and UVs stay `Vec2` rather than going back to `[f32; 2]`. - `ImageGpu` no longer holds the `Texture` behind its view, which removes an `#[allow(dead_code)]`. A `TextureView` keeps its own reference to the texture, checked by rendering rather than assumed -- see below. ### Verification ``` cargo fmt --all --check cargo clippy --workspace --all-targets --locked -- -D warnings cargo test --workspace --locked ``` All clean; the 4 text-edit tests pass. The only clippy output is the pre-existing future-incompatibility notice about `naga`/`wgpu`/`winit`. Because this is a rendering change, it was also run for real rather than only compiled. The `tabs` example was rendered on this machine's GPU -- Venus onto an RX 7900 XT, confirmed from the loaded ICD (`libvulkan_virtio.so` on `/dev/dri/renderD128`) rather than assumed, since a failed Vulkan init here silently falls back to llvmpipe and would make the screenshots meaningless. Screenshots before and after the change are **byte-identical** (same md5) in two scenes: the default tab, which exercises text (the atlas path) and rects, and the image tab with a standalone image pushed at startup, which exercises the per-image bind group. The image-tab scene needed a temporary local edit to the example to push the image without a click; that edit is not part of this branch. The same comparison, re-run after dropping the `Texture` field, is still byte-identical -- which is the check that the view alone keeps it alive. --------- Co-authored-by: iris <2+iris@noreply.localhost> Reviewed-on: iris/iris#11 Reviewed-by: iris <2+iris@noreply.localhost> Co-authored-by: AIris <4+iris-ai@noreply.localhost>
283 lines
7.8 KiB
Rust
283 lines
7.8 KiB
Rust
use crate::{
|
|
Align, GlyphAtlas, GlyphEntry, GlyphKey, PlacedGlyph, RegionAlign, UiColor, util::Vec2,
|
|
};
|
|
use parley::{
|
|
Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, GenericFamily, Layout,
|
|
LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty,
|
|
};
|
|
use std::hash::{DefaultHasher, Hash, Hasher};
|
|
use swash::{
|
|
FontRef,
|
|
scale::{Render, ScaleContext, Source, StrikeWith},
|
|
zeno::{Format, Vector},
|
|
};
|
|
|
|
pub struct TextData {
|
|
pub font_ctx: FontContext,
|
|
pub layout_ctx: LayoutContext<UiColor>,
|
|
scale_ctx: ScaleContext,
|
|
pub atlas: GlyphAtlas,
|
|
}
|
|
|
|
impl Default for TextData {
|
|
fn default() -> Self {
|
|
Self {
|
|
font_ctx: FontContext::new(),
|
|
layout_ctx: LayoutContext::new(),
|
|
scale_ctx: ScaleContext::new(),
|
|
atlas: GlyphAtlas::default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, PartialEq)]
|
|
pub enum Family {
|
|
SansSerif,
|
|
Serif,
|
|
Monospace,
|
|
Named(String),
|
|
}
|
|
|
|
impl Family {
|
|
fn family(&self) -> FontFamily<'_> {
|
|
let name = match self {
|
|
Self::SansSerif => FontFamilyName::Generic(GenericFamily::SansSerif),
|
|
Self::Serif => FontFamilyName::Generic(GenericFamily::Serif),
|
|
Self::Monospace => FontFamilyName::Generic(GenericFamily::Monospace),
|
|
Self::Named(name) => FontFamilyName::Named(name.as_str().into()),
|
|
};
|
|
FontFamily::Single(name)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, PartialEq)]
|
|
pub struct TextAttrs {
|
|
pub color: UiColor,
|
|
pub font_size: f32,
|
|
pub line_height: f32,
|
|
pub family: Family,
|
|
pub wrap: bool,
|
|
pub align: RegionAlign,
|
|
}
|
|
|
|
pub const LINE_HEIGHT_MULT: f32 = 1.1;
|
|
|
|
impl Default for TextAttrs {
|
|
fn default() -> Self {
|
|
let size = 16.0;
|
|
Self {
|
|
color: UiColor::WHITE,
|
|
font_size: size,
|
|
line_height: size * LINE_HEIGHT_MULT,
|
|
family: Family::SansSerif,
|
|
wrap: false,
|
|
align: Align::CENTER_LEFT,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Keeps text and its corresponding layout from getting out of sync.
|
|
pub struct TextBuffer {
|
|
text: String,
|
|
layout: Layout<UiColor>,
|
|
layout_key: Option<LayoutKey>,
|
|
}
|
|
|
|
#[derive(PartialEq)]
|
|
struct LayoutKey {
|
|
attrs: TextAttrs,
|
|
max_width: Option<f32>,
|
|
}
|
|
|
|
impl TextBuffer {
|
|
pub fn new(text: impl Into<String>) -> Self {
|
|
Self {
|
|
text: text.into(),
|
|
layout: Layout::new(),
|
|
layout_key: None,
|
|
}
|
|
}
|
|
|
|
pub fn new_empty() -> Self {
|
|
Self::new("")
|
|
}
|
|
|
|
pub fn text(&self) -> &str {
|
|
&self.text
|
|
}
|
|
|
|
pub fn layout(&self) -> &Layout<UiColor> {
|
|
&self.layout
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.text.is_empty()
|
|
}
|
|
|
|
pub fn set_text(&mut self, text: impl Into<String>) {
|
|
let text = text.into();
|
|
if text != self.text {
|
|
self.text = text;
|
|
self.layout_key = None;
|
|
}
|
|
}
|
|
|
|
/// Invalidates the layout and returns the underlying string for editing.
|
|
pub fn edit(&mut self) -> &mut String {
|
|
self.layout_key = None;
|
|
&mut self.text
|
|
}
|
|
|
|
pub fn size(&self) -> Vec2 {
|
|
Vec2::new(self.layout.width(), self.layout.height())
|
|
}
|
|
|
|
pub fn shape(&mut self, data: &mut TextData, attrs: &TextAttrs, width: Option<f32>) {
|
|
let layout_key = LayoutKey {
|
|
attrs: attrs.clone(),
|
|
max_width: width,
|
|
};
|
|
if self.layout_key.as_ref() == Some(&layout_key) {
|
|
return;
|
|
}
|
|
let mut builder = data
|
|
.layout_ctx
|
|
.ranged_builder(&mut data.font_ctx, &self.text, 1.0, true);
|
|
builder.push_default(StyleProperty::FontFamily(attrs.family.family()));
|
|
builder.push_default(StyleProperty::FontSize(attrs.font_size));
|
|
builder.push_default(StyleProperty::LineHeight(LineHeight::Absolute(
|
|
attrs.line_height,
|
|
)));
|
|
builder.push_default(StyleProperty::Brush(attrs.color));
|
|
builder.build_into(&mut self.layout, &self.text);
|
|
self.layout.break_all_lines(width);
|
|
self.layout
|
|
.align(Alignment::Start, AlignmentOptions::default());
|
|
self.layout_key = Some(layout_key);
|
|
}
|
|
}
|
|
|
|
impl TextData {
|
|
pub fn place(&mut self, buffer: &TextBuffer) -> Vec<PlacedGlyph> {
|
|
let mut placed = Vec::new();
|
|
for line in buffer.layout.lines() {
|
|
for item in line.items() {
|
|
let PositionedLayoutItem::GlyphRun(run) = item else {
|
|
continue;
|
|
};
|
|
let font = run.run().font();
|
|
let font_size = run.run().font_size();
|
|
let coords = run.run().normalized_coords();
|
|
let Some(font_ref) = FontRef::from_index(font.data.as_ref(), font.index as usize)
|
|
else {
|
|
continue;
|
|
};
|
|
let coords_hash = hash_coords(coords);
|
|
let font_id = font.data.id();
|
|
|
|
for glyph in run.positioned_glyphs() {
|
|
let subpixel = ((glyph.x.fract() * 4.0).round() as i32).rem_euclid(4) as u8;
|
|
let key = GlyphKey {
|
|
font: font_id,
|
|
glyph: glyph.id,
|
|
size: glyph_size_key(font_size),
|
|
subpixel,
|
|
coords: coords_hash,
|
|
};
|
|
let Some(entry) = self.glyph_entry(GlyphRaster {
|
|
key,
|
|
font: font_ref,
|
|
font_size,
|
|
coords,
|
|
subpixel,
|
|
glyph_id: glyph.id,
|
|
}) else {
|
|
continue;
|
|
};
|
|
placed.push(PlacedGlyph {
|
|
entry,
|
|
offset: Vec2::new(
|
|
glyph.x.floor() + entry.left as f32,
|
|
glyph.y.floor() - entry.top as f32,
|
|
),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
placed
|
|
}
|
|
|
|
fn glyph_entry(&mut self, glyph: GlyphRaster<'_>) -> Option<GlyphEntry> {
|
|
if let Some(entry) = self.atlas.get(&glyph.key) {
|
|
return entry;
|
|
}
|
|
|
|
let mut scaler = self
|
|
.scale_ctx
|
|
.builder(glyph.font)
|
|
.size(glyph.font_size)
|
|
.hint(true)
|
|
.normalized_coords(glyph.coords)
|
|
.build();
|
|
let image = Render::new(&[
|
|
Source::ColorOutline(0),
|
|
Source::ColorBitmap(StrikeWith::BestFit),
|
|
Source::Outline,
|
|
])
|
|
.format(Format::Alpha)
|
|
.offset(Vector::new(glyph.subpixel as f32 / 4.0, 0.0))
|
|
.render(&mut scaler, glyph.glyph_id as u16);
|
|
|
|
if let Some(image) = image {
|
|
self.atlas.insert(glyph.key, &image)
|
|
} else {
|
|
self.atlas.insert_empty(glyph.key);
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
struct GlyphRaster<'a> {
|
|
key: GlyphKey,
|
|
font: FontRef<'a>,
|
|
font_size: f32,
|
|
coords: &'a [i16],
|
|
subpixel: u8,
|
|
glyph_id: u32,
|
|
}
|
|
|
|
fn hash_coords(coords: &[i16]) -> u64 {
|
|
let mut hasher = DefaultHasher::new();
|
|
coords.hash(&mut hasher);
|
|
hasher.finish()
|
|
}
|
|
|
|
const GLYPH_SIZE_STEPS_PER_PIXEL: f32 = 16.0;
|
|
|
|
fn glyph_size_key(font_size: f32) -> u32 {
|
|
(font_size * GLYPH_SIZE_STEPS_PER_PIXEL).round() as u32
|
|
}
|
|
|
|
pub struct RenderedText {
|
|
pub glyphs: Vec<PlacedGlyph>,
|
|
pub size: Vec2,
|
|
pub color: UiColor,
|
|
}
|
|
|
|
impl TextData {
|
|
pub fn render(
|
|
&mut self,
|
|
buffer: &mut TextBuffer,
|
|
attrs: &TextAttrs,
|
|
width: Option<f32>,
|
|
) -> RenderedText {
|
|
buffer.shape(self, attrs, width);
|
|
let glyphs = self.place(buffer);
|
|
RenderedText {
|
|
glyphs,
|
|
size: buffer.size(),
|
|
color: attrs.color,
|
|
}
|
|
}
|
|
}
|