绘制与着色器
需要内置组件和修饰符之外的视觉效果时,可以用 WGSL 着色器自己绘制。自定义绘制分两部分:一个带类型的命令,描述要画什么;一条你注册的 GPU 管线,负责把它画出来。组件在绘制阶段记录命令,渲染器再把命令分批交给你的管线。
写管线之前
大多数视觉效果来自现有的修饰符和组件。填充、描边、阴影和形状裁剪来自 background、border、shadow、clip_shape 和 clip_to_bounds 修饰符,它们的用法和顺序见修饰符。位图、SVG 和图标交给接收 .painter(...) 的图片和图标组件,见资源。各种 Material 表面,包括玻璃材质,属于 Material 3 主题的内容。
超出这些范围的效果,就写一条管线:比如程序生成的填充、图表渲染器,或者要读取背后场景的模糊、扭曲效果。
在组件中绘制
组件这一侧的代码很少。下面沿用自定义着色器示例中的 layout().draw(...) 写法,给一个固定尺寸的布局节点挂上绘制策略:
use tessera_components::modifier::ModifierExt as _;
use tessera_ui::{Dp, Modifier, layout::layout, tessera};
#[tessera]
fn shader_canvas(time_seconds: f32) {
layout()
.modifier(Modifier::new().size(Dp(560.0), Dp(320.0)))
.draw(CanvasCommand { time_seconds, opacity: 1.0 });
}布局原语用来实现这类自定义渲染组件,排布应用界面则继续使用普通的应用容器。
下文把 Rust 自定义着色器示例及其 WGSL简化成一个动态填色;完整示例还包含相机 uniform 和光线追踪。这些片段展示的是你的代码在哪里接入 Tessera,不是可以直接复制运行的完整管线。凡是省略了管线创建或 GPU 资源绑定的地方,下文都会明确说明。
时间作为普通的组件状态传给着色器。动画进行期间,用帧时钟驱动这个值:帧时钟负责安排新帧,uniform 负责把每次的新值带给着色器。
记录绘制命令
这里需要两个 trait:DrawCommand 描述管线收到的数据,DrawModifierNode 是负责记录这些数据的策略。简单的命令类型可以同时实现二者:
use tessera_ui::{DrawCommand, DrawModifierContent, DrawModifierContext, DrawModifierNode};
#[derive(Clone, Copy, Debug, PartialEq)]
struct CanvasCommand {
time_seconds: f32,
opacity: f32,
}
impl DrawCommand for CanvasCommand {
fn apply_opacity(&mut self, opacity: f32) {
self.opacity *= opacity.clamp(0.0, 1.0);
}
}
impl DrawModifierNode for CanvasCommand {
fn draw(
&self,
ctx: &mut DrawModifierContext<'_, '_>,
content: &mut dyn DrawModifierContent,
) {
let input = &mut *ctx.render_input;
input.metadata_mut().fragment_mut().push_draw_command(*self);
content.draw(input);
}
}这里的顺序有讲究:在 content.draw(input) 之前记录命令,画在被包裹内容的后面;之后记录,则画在内容上面。绘制策略拿到的是它在修饰符链中所处位置的边界。
apply_opacity 必须作用到输出的 alpha 上,这样祖先淡出时,你画的内容也会跟着淡出。着色器的颜色表示要和管线的混合状态一致;本例使用非预乘颜色和 wgpu::BlendState::ALPHA_BLENDING。
编写 WGSL
示例在着色器里根据顶点索引生成六个顶点,无需顶点缓冲区。下面的精简版保留了从组件矩形到裁剪空间的映射,把光线追踪换成了移动的色彩场:
struct Uniforms {
rect: vec4<f32>,
time: f32,
opacity: f32,
screen_size: vec2<f32>,
};
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) coord: vec2<f32>,
};
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@vertex
fn vs_main(@builtin(vertex_index) index: u32) -> VertexOutput {
let vertices = array<vec2<f32>, 6>(
vec2<f32>(0.0, 0.0), vec2<f32>(1.0, 0.0),
vec2<f32>(1.0, 1.0), vec2<f32>(0.0, 0.0),
vec2<f32>(1.0, 1.0), vec2<f32>(0.0, 1.0),
);
let local = vertices[index];
let pixel_pos = uniforms.rect.xy + local * uniforms.rect.zw;
let clip = vec2<f32>(
pixel_pos.x / uniforms.screen_size.x * 2.0 - 1.0,
pixel_pos.y / uniforms.screen_size.y * -2.0 + 1.0,
);
var out: VertexOutput;
out.position = vec4<f32>(clip, 0.0, 1.0);
out.coord = local;
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let wave = 0.5 + 0.5 * sin(in.coord.x * 12.0 + uniforms.time);
return vec4<f32>(0.2 * wave, 0.4 * wave, wave, uniforms.opacity);
}Rust 侧对应的 uniform 载荷是八个 f32:矩形原点和尺寸、时间、不透明度,以及渲染目标的宽高。Rust 缓冲区的布局必须符合 WGSL 的对齐规则。创建着色器模块时,可以用 wgpu::ShaderSource::Wgsl(include_str!("canvas.wgsl").into()) 加载文件。
注册管线
你的管线实现 DrawablePipeline<CanvasCommand>,再由一个 RenderModule 注册。下面的片段是省略版:它假设应用已经定义了 CanvasPipeline::new 构造函数,构造函数里的 GPU 初始化代码没有写出:
use tessera_ui::{PipelineContext, RenderModule};
struct CanvasModule;
impl RenderModule for CanvasModule {
fn register_pipelines(&self, context: &mut PipelineContext<'_>) {
let resources = context.resources();
let pipeline = CanvasPipeline::new(
resources.device,
resources.surface_config,
resources.pipeline_cache,
resources.sample_count,
);
context.register_draw_pipeline(pipeline);
}
}要安装这个模块,在已有的 EntryPoint 上加 .module(CanvasModule),或者在 TesseraPackage 里调用 EntryRegistry::add_module,见应用入口与窗口。如果用的是标准组件,ComponentsPackage 也要继续注册。
创建 WGPU 渲染管线时,使用传入的表面格式和采样数。UI 管线还必须设置 depth_stencil: tessera_ui::ui_stencil_state(),让形状裁剪作用到它的输出上:形状裁剪用到的模板附件和参考值由渲染器提供,没有这个状态的管线会忽略形状裁剪。
绘制批次
渲染器调用 DrawablePipeline::draw 时传入 DrawContext,里面有当前的渲染通道、本批次的命令与几何信息、目标尺寸和裁剪矩形。通道的边界和提交都由渲染器负责。如果需要在此前后做准备或收尾,begin_frame 在各通道开始前准备资源,end_frame 在所有通道结束后、提交之前执行。
下面是省略了实现细节的方法体,不是完整的管线实现。绑定管线、分配和上传每条命令的 uniform,以及绑定对应缓冲区范围的代码都没有写出:
use tessera_ui::renderer::drawer::pipeline::{DrawContext, DrawablePipeline};
impl DrawablePipeline<CanvasCommand> for CanvasPipeline {
fn draw(&mut self, context: &mut DrawContext<'_, '_, '_, CanvasCommand>) {
for (_command, geometry) in context.commands {
let Some((x, y, width, height)) =
resolve_scissor_rect(*geometry, context.clip_rect, context.target_size)
else {
continue;
};
// Bind this command's pipeline and uniform range here.
context.render_pass.set_scissor_rect(x, y, width, height);
context.render_pass.draw(0..6, 0..1);
}
}
}矩形 uniform 用 geometry.window_origin() 和 geometry.window_size() 填充。圆角半径、描边宽度这类局部量则不同:它们应基于 geometry.local_size,并且只应用一次 geometry.local_to_window。变换后的窗口尺寸已经包含了缩放,拿它当布局测量值用,缩放就会被算两次。
注意
请给同一批次里的每次绘制分配各自的缓冲区范围或实例,让它们拥有独立、稳定的 uniform 数据。反复向同一段缓冲区写入队列,并不会为每次绘制留下快照,结果所有绘制看到的都是最后一次写入。缓冲区和绑定组只创建一次,在各帧和各条命令之间复用。
裁剪到绘制区域
scissor 矩形必须取命令绘制区域、context.clip_rect 和目标边界三者的交集。小数边界先向外取整,再转换成整数 scissor 坐标,这样边缘像素才能完整留在 scissor 内。对于只绘制自身局部矩形的命令,示例使用了这个辅助函数:
use tessera_ui::{DrawGeometry, Px, PxRect, PxSize};
fn resolve_scissor_rect(
geometry: DrawGeometry,
clip_rect: Option<PxRect>,
target_size: PxSize,
) -> Option<(u32, u32, u32, u32)> {
let viewport = PxRect::new(Px::ZERO, Px::ZERO, target_size.width, target_size.height);
let base = geometry.window_bounds().outward();
let clipped = match clip_rect {
Some(clip) => base.intersection(&clip)?,
None => base,
}.intersection(&viewport)?;
if clipped.width.raw() <= 0 || clipped.height.raw() <= 0 {
return None;
}
Some((clipped.x.positive(), clipped.y.positive(),
clipped.width.positive(), clipped.height.positive()))
}如果你的 draw_region() 超出了局部边界,就应根据声明的输出区域计算 scissor。scissor 只能限定一个矩形,形状裁剪由渲染器的模板状态另行处理。相关的坐标空间见自定义布局。
采样场景
模糊、扭曲这类效果需要读取已经画好的像素。为此要覆盖 DrawCommand::sample_region(),它默认返回 None。两种区域要分清:SampleRegion 声明输入范围,DrawRegion 声明输出范围。扩大采样范围时,布局和绘制范围保持原样。
| 区域 | 含义 |
|---|---|
SampleRegion::PaddedLocal(PaddingRect) | 组件边界加上局部留白,必须覆盖滤镜或扭曲的完整采样范围。 |
SampleRegion::Absolute(PxRect) | 明确的窗口坐标矩形。 |
SampleRegion::Global | 整个场景,需要全屏复制。 |
捕获的场景通过 DrawContext::scene_texture_view 传入。它的第一个纹素对应 scene_rect 在窗口中的原点,它可能与窗口左上角不同;分配的纹理也可能比有效矩形大。采样时,先从窗口坐标中减去这个原点,再按纹理的实际尺寸归一化 UV,并且只读取有效的捕获像素。
采样范围和绘制裁剪相互独立:一个被裁剪的模糊效果,仍可能需要可见输出之外的像素。请声明完整的依赖范围,让纹理包含效果读取的每一个像素。有界区域能让复制量保持在较小范围,Global 留给确实需要整个场景的效果。
计算效果
如果场景处理更适合用计算着色器而不是绘制,就实现 ComputeCommand::barrier() -> SampleRegion 和 ComputablePipeline::dispatch(&mut self, context: &mut ComputeContext<C>)。在同一个渲染模块中用 PipelineContext::register_compute_pipeline 注册管线,再在绘制策略里通过 input.metadata_mut().fragment_mut().push_compute_command(command) 记录命令。
ComputeContext 提供 input_view、output_view、compute_pass 和带类型的 items。每一项都带有命令、几何信息,以及以通道纹理坐标表示的 target_area。通道纹理覆盖整个批次采样区域与输出区域的并集,并且可能相对窗口有偏移,所以要以提供的几何信息和目标区域为准。
输出一开始是输入的副本,使用非 sRGB 的存储格式。只在每一项的目标区域内 dispatch,在着色器中检查区域边缘,其余像素保持不变;下一个采样场景的绘制会读取计算结果。计算通道没有 UI 裁剪,它的写入范围只受你自己编写的检查约束。
局部重绘
DrawCommand::supports_partial_redraw() 默认返回 false。确认每一种视觉变化都会让命令的绘制片段或几何信息失效之后,再开启它。可变纹理、延迟上传以及外部的 GPU 修改,都可能让相同的命令数据画出不同的像素。
默认的 draw_region() 是零留白的局部边界。阴影这类会画到边界之外的效果,应声明更大的输出范围,让损伤追踪覆盖所有受影响的像素。无论绘制命令是否开启这个选项,场景采样和计算任务始终需要保守重绘。命令数据没变,也说明不了外部资源的情况,它们可能自行发生变化。