23. 表示リストと描画 — 「何をどこに描くか」の命令列にしてから描く
本物のブラウザは、レイアウトの結果を直接画面に描きません。一度「何をどこに描くか」の命令列(、display list)にしてから、それを GPU に渡します。描く対象()と描く手段(画面・プリンタ・画像ファイル)を切り離すためです。この実装も同じ順で、 が LayoutBox の木から DisplayItem の列を作り、 がそれを文字のキャンバスに描きます。
DisplayItem は 2 種類です。Rect(背景の四角: 位置・大きさ・色)と Text(1 行分の文字列: 位置・文字列・色・font-size・下線の有無)。paint_box は、に background-color があれば先に Rect を出し、文字の箱なら後の各行を Text として出し、それから子を辿ります。親の背景を先に、子を後に出すので、後に描いたものが上に重なる(文字が背景の上に見える)ことになります。本物の描画順(stacking)はもっと複雑ですが、骨格は同じです。
render_ascii は、1 セルを 8×24px(16px の文字 1 つが 1 行に収まる大きさ)として、幅 640px なら 80 列のグリッドを作り、Rect は白以外なら網掛け、Text は文字をそのセル位置に書きます。font-size が大きい文字も 1 セル 1 文字で書くので、本物と違って大きくは見えませんが、行の高さは広がります。下線は空白のところを _ にして示します。
前のステップ(step10)を実行すると、表示リストの全命令(Rect が 1 つ、Text が行ごと)と、文字のキャンバスが出ます。表示リストの Text の x, y がレイアウトで決めた座標と一致していること、 の段落が命令に現れないことを確かめてください。
本物のブラウザでは: は BoxFragmentPainter が(display item)を PaintController に積み、同じ座標変換・クリップを共有する連続した項目を にまとめます。PaintController は「前回の描画結果を表示項目のキャッシュとして保つ」ので、変わらない部分は描き直しません。その後 PaintArtifactCompositor が の列に変換し、cc がの木 → pending tree → active tree へ commit / activate して、タイルに分けてワーカースレッドでし、の が各クライアントのフレームを 1 枚に合成します(docs/how_cc_works.md、components/viz/README.md)。Firefox は nsDisplayList を の表示リストに直列化して GPU プロセスへ送り、Rust の WebRender が Scene → Frame → GPU 命令にします。この回の DisplayItem と文字キャンバスは、表示リストとラスタライズを 1 段に潰したものです。
開発のどこに活かすか: 「描く内容」と「描く手段」を命令列で切り離す形は、Canvas や PDF 生成、帳票、グラフ描画ライブラリ、UI の(React の木と への反映、Flutter のレイヤー)に共通する設計です。レンダリングを自前で書く場面がなくても、「レイアウトは済んでいるのに描画が重い」と「レイアウトが重い」を切り分けられる(開発者ツールの Paint と Layout の計測が別物である)根拠になります。
点線の付いた語はタップすると説明が出ます。用語集を開く →
サンプルコード(フレームワーク環境が必要なため表示のみ)
// src/renderer/paint.rs(表示リストと文字のキャンバス)
//! 描画。箱の木を「表示リスト(display list)」= 描く命令の列に変え、さらに文字のキャンバスに描く。
//!
//! 本物のブラウザも、レイアウトの結果を直接画面に描くのではなく、
//! 一度「何をどこに描くか」の命令列(display list / paint record)にしてから GPU に渡す。
//! この実装はウィンドウを持たないので、命令列を 1 文字 = 8×16px のセルからなるキャンバスに描いて
//! 標準出力に出す。
use crate::renderer::layout::{BoxKind, LayoutBox};
use crate::renderer::style::{Color, TextDecoration};
#[derive(Debug, Clone, PartialEq)]
pub enum DisplayItem {
/// 背景の四角
Rect {
x: f32,
y: f32,
width: f32,
height: f32,
color: Color,
},
/// 1 行分の文字列
Text {
x: f32,
y: f32,
text: String,
color: Color,
font_size: f32,
underline: bool,
},
}
/// 箱の木 → 表示リスト。親の背景を先に、文字を後に(後に描いたものが上に来る)
pub fn paint(root: &LayoutBox) -> Vec<DisplayItem> {
let mut items = Vec::new();
paint_box(root, &mut items);
items
}
fn paint_box(b: &LayoutBox, items: &mut Vec<DisplayItem>) {
match &b.kind {
BoxKind::Block | BoxKind::Inline => {
if let Some(bg) = b.style.background_color {
items.push(DisplayItem::Rect {
x: b.x,
y: b.y,
width: b.width,
height: b.height,
color: bg,
});
}
}
BoxKind::Text(_) => {
for line in &b.lines {
items.push(DisplayItem::Text {
x: line.x,
y: line.y,
text: line.text.clone(),
color: b.style.color,
font_size: b.style.font_size,
underline: b.style.text_decoration == TextDecoration::Underline,
});
}
}
}
for c in &b.children {
paint_box(c, items);
}
}
/// 1 セルの大きさ(px)。16px の文字 1 つが 1 セルに収まる想定
pub const CELL_WIDTH: f32 = 8.0;
pub const CELL_HEIGHT: f32 = 24.0; // 16px × 行の高さの比 1.5
/// 表示リストを文字のキャンバスに描く。幅は px で与える
pub fn render_ascii(items: &[DisplayItem], width_px: f32) -> String {
let cols = (width_px / CELL_WIDTH).ceil() as usize;
let bottom = items
.iter()
.map(|i| match i {
DisplayItem::Rect { y, height, .. } => y + height,
DisplayItem::Text { y, font_size, .. } => y + font_size * 1.5,
})
.fold(0.0_f32, f32::max);
let rows = (bottom / CELL_HEIGHT).ceil().max(1.0) as usize;
let mut grid = vec![vec![' '; cols]; rows];
for item in items {
match item {
DisplayItem::Rect { x, y, width, height, color } => {
if *color == Color::WHITE {
continue;
}
let (c0, r0) = (cell(*x, CELL_WIDTH), cell(*y, CELL_HEIGHT));
let c1 = cell(x + width, CELL_WIDTH).min(cols);
let r1 = cell(y + height, CELL_HEIGHT).min(rows);
for row in grid.iter_mut().take(r1).skip(r0) {
for ch in row.iter_mut().take(c1).skip(c0) {
*ch = '░';
}
}
}
DisplayItem::Text { x, y, text, underline, .. } => {
let (mut c, r) = (cell(*x, CELL_WIDTH), cell(*y, CELL_HEIGHT));
if r >= rows {
continue;
}
for ch in text.chars() {
if c >= cols {
break;
}
grid[r][c] = if *underline && ch == ' ' { '_' } else { ch };
c += 1;
}
}
}
}
let mut out = String::new();
out.push('┌');
out.push_str(&"─".repeat(cols));
out.push_str("┐\n");
for row in grid {
out.push('│');
out.extend(row);
out.push_str("│\n");
}
out.push('└');
out.push_str(&"─".repeat(cols));
out.push('┘');
out
}
fn cell(px: f32, size: f32) -> usize {
(px / size).floor().max(0.0) as usize
}
/// 表示リストを 1 行 1 命令で並べる(学習用)
pub fn dump_display_list(items: &[DisplayItem]) -> String {
items
.iter()
.map(|i| match i {
DisplayItem::Rect { x, y, width, height, color } => {
format!("Rect x={x:.0} y={y:.0} w={width:.0} h={height:.0} color={}", color.hex())
}
DisplayItem::Text { x, y, text, color, font_size, underline } => format!(
"Text x={x:.0} y={y:.0} {text:?} color={} size={font_size:.0}px{}",
color.hex(),
if *underline { " underline" } else { "" }
),
})
.collect::<Vec<_>>()
.join("\n")
}
手元で動かす(配布物)
- ステップ 10 の 1 ファイル版(step10_paint.rs)実行すると表示リストとキャンバスが出る。テスト 48 本
- 完成版のプロジェクト(tinybrowser.zip)
図やコードに出てくる用語(タップで説明)
学んだ概念を、素朴な例から実務寄りの例まで3つの具体例で確認します。
公式ドキュメントで詳しく ↗