Add the Pixel Preview render mode (#3881)

* Add pixel preview render mode

* Fix fmt

* Remove unused sampler

* Remove unnecessary mutex

* Code review

---------

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
Timon 2026-03-11 03:44:00 +01:00 committed by GitHub
parent 35b812ccfe
commit 095c2a6d47
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 320 additions and 19 deletions

View File

@ -1,3 +1,7 @@
// =============
// VERTEX SHADER
// =============
struct VertexOutput { struct VertexOutput {
@builtin(position) clip_position: vec4<f32>, @builtin(position) clip_position: vec4<f32>,
@location(0) tex_coords: vec2<f32>, @location(0) tex_coords: vec2<f32>,
@ -6,19 +10,21 @@ struct VertexOutput {
@vertex @vertex
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
var out: VertexOutput; var out: VertexOutput;
let pos = array( let pos = array(
vec2f( -1.0, -1.0), vec2f(-1.0, -1.0),
vec2f( 3.0, -1.0), vec2f(3.0, -1.0),
vec2f( -1.0, 3.0), vec2f(-1.0, 3.0),
); );
let xy = pos[vertex_index]; let xy = pos[vertex_index];
out.clip_position = vec4f(xy , 0.0, 1.0); out.clip_position = vec4f(xy, 0.0, 1.0);
let coords = (xy / 2. + 0.5); let coords = xy / 2. + 0.5;
out.tex_coords = vec2f(coords.x, 1. - coords.y); out.tex_coords = vec2f(coords.x, 1. - coords.y);
return out; return out;
} }
// ===============
// FRAGMENT SHADER
// ===============
struct Constants { struct Constants {
viewport_scale: vec2<f32>, viewport_scale: vec2<f32>,

View File

@ -2519,11 +2519,12 @@ impl DocumentMessageHandler {
.icon("RenderModeOutline") .icon("RenderModeOutline")
.tooltip_label("Render Mode: Outline") .tooltip_label("Render Mode: Outline")
.on_update(|_| DocumentMessage::SetRenderMode { render_mode: RenderMode::Outline }.into()), .on_update(|_| DocumentMessage::SetRenderMode { render_mode: RenderMode::Outline }.into()),
// TODO: See issue #320 RadioEntryData::new("PixelPreview").icon("RenderModePixels").tooltip_label("Render Mode: Pixel Preview").on_update(|_| {
// RadioEntryData::new("PixelPreview") DocumentMessage::SetRenderMode {
// .icon("RenderModePixels") render_mode: RenderMode::PixelPreview,
// .tooltip_label("Render Mode: Pixel Preview") }
// .on_update(|_| todo!()), .into()
}),
RadioEntryData::new("SvgPreview") RadioEntryData::new("SvgPreview")
.icon("RenderModeSvg") .icon("RenderModeSvg")
.tooltip_label("Render Mode: SVG Preview") .tooltip_label("Render Mode: SVG Preview")
@ -2534,7 +2535,7 @@ impl DocumentMessageHandler {
if disabled { if disabled {
for entry in &mut entries { for entry in &mut entries {
entry.tooltip_description = " entry.tooltip_description = "
*Normal* and *Outline* render modes are not available in this browser. For compatibility, *SVG Preview* mode is active as a fallback.\n\ *Normal*, *Outline*, and *Pixel Preview* render modes are not available in this browser. For compatibility, *SVG Preview* mode is active as a fallback.\n\
\n\ \n\
This functionality requires WebGPU support. Check webgpu.org for browser implementation status. This functionality requires WebGPU support. Check webgpu.org for browser implementation status.
" "

View File

@ -28,7 +28,7 @@ pub fn wrap_network_in_scope(mut network: NodeNetwork, editor_api: Arc<WasmEdito
let render_node = DocumentNode { let render_node = DocumentNode {
inputs: vec![NodeInput::node(NodeId(0), 0)], inputs: vec![NodeInput::node(NodeId(0), 0)],
implementation: DocumentNodeImplementation::Network(NodeNetwork { implementation: DocumentNodeImplementation::Network(NodeNetwork {
exports: vec![NodeInput::node(NodeId(3), 0)], exports: vec![NodeInput::node(NodeId(4), 0)],
nodes: [ nodes: [
DocumentNode { DocumentNode {
call_argument: concrete!(Context), call_argument: concrete!(Context),
@ -61,9 +61,19 @@ pub fn wrap_network_in_scope(mut network: NodeNetwork, editor_api: Arc<WasmEdito
}, },
..Default::default() ..Default::default()
}, },
DocumentNode {
call_argument: concrete!(Context),
inputs: vec![NodeInput::scope("editor-api"), NodeInput::node(NodeId(2), 0)],
implementation: DocumentNodeImplementation::ProtoNode(graphene_std::pixel_preview::pixel_preview::IDENTIFIER),
context_features: graphene_std::ContextDependencies {
extract: ContextFeatures::FOOTPRINT | ContextFeatures::VARARGS,
inject: ContextFeatures::FOOTPRINT | ContextFeatures::VARARGS,
},
..Default::default()
},
DocumentNode { DocumentNode {
call_argument: concrete!(graphene_std::application_io::RenderConfig), call_argument: concrete!(graphene_std::application_io::RenderConfig),
inputs: vec![NodeInput::node(NodeId(2), 0)], inputs: vec![NodeInput::node(NodeId(3), 0)],
implementation: DocumentNodeImplementation::ProtoNode(graphene_std::render_node::create_context::IDENTIFIER), implementation: DocumentNodeImplementation::ProtoNode(graphene_std::render_node::create_context::IDENTIFIER),
context_features: graphene_std::ContextDependencies { context_features: graphene_std::ContextDependencies {
// We add the extract index annotation here to force the compiler to add a context nullification node before this node so the render context is properly nullified so the render cache node can do its's work // We add the extract index annotation here to force the compiler to add a context nullification node before this node so the render context is properly nullified so the render cache node can do its's work

View File

@ -665,8 +665,8 @@ pub enum RenderMode {
Normal = 0, Normal = 0,
/// Render only the outlines of shapes at the current viewport resolution /// Render only the outlines of shapes at the current viewport resolution
Outline, Outline,
// /// Render with normal coloration at the document resolution, showing the pixels when the current viewport resolution is higher /// Render with normal coloration at the document export resolution; at zoom > 100% this shows individual export pixels upscaled with nearest-neighbor filtering
// PixelPreview, PixelPreview,
/// Render a preview of how the object would be exported as an SVG. /// Render a preview of how the object would be exported as an SVG.
SvgPreview, SvgPreview,
} }

View File

@ -1,7 +1,9 @@
mod context; mod context;
mod resample;
pub mod shader_runtime; pub mod shader_runtime;
pub mod texture_conversion; pub mod texture_conversion;
use crate::resample::Resampler;
use crate::shader_runtime::ShaderRuntime; use crate::shader_runtime::ShaderRuntime;
use anyhow::Result; use anyhow::Result;
use core_types::Color; use core_types::Color;
@ -9,7 +11,6 @@ use dyn_any::StaticType;
use futures::lock::Mutex; use futures::lock::Mutex;
use glam::UVec2; use glam::UVec2;
use graphene_application_io::{ApplicationIo, EditorApi, SurfaceHandle, SurfaceId}; use graphene_application_io::{ApplicationIo, EditorApi, SurfaceHandle, SurfaceId};
pub use rendering::RenderContext;
use std::sync::Arc; use std::sync::Arc;
use vello::{AaConfig, AaSupport, RenderParams, Renderer, RendererOptions, Scene}; use vello::{AaConfig, AaSupport, RenderParams, Renderer, RendererOptions, Scene};
use wgpu::util::TextureBlitter; use wgpu::util::TextureBlitter;
@ -17,6 +18,7 @@ use wgpu::{Origin3d, TextureAspect};
pub use context::Context as WgpuContext; pub use context::Context as WgpuContext;
pub use context::ContextBuilder as WgpuContextBuilder; pub use context::ContextBuilder as WgpuContextBuilder;
pub use rendering::RenderContext;
pub use wgpu::Backends as WgpuBackends; pub use wgpu::Backends as WgpuBackends;
pub use wgpu::Features as WgpuFeatures; pub use wgpu::Features as WgpuFeatures;
@ -24,6 +26,7 @@ pub use wgpu::Features as WgpuFeatures;
pub struct WgpuExecutor { pub struct WgpuExecutor {
pub context: WgpuContext, pub context: WgpuContext,
vello_renderer: Mutex<Renderer>, vello_renderer: Mutex<Renderer>,
resampler: Resampler,
pub shader_runtime: ShaderRuntime, pub shader_runtime: ShaderRuntime,
} }
@ -154,6 +157,10 @@ impl WgpuExecutor {
Ok(()) Ok(())
} }
pub fn resample_texture(&self, source: &wgpu::Texture, target_size: UVec2, transform: &glam::DAffine2) -> wgpu::Texture {
self.resampler.resample(&self.context, source, target_size, transform)
}
#[cfg(target_family = "wasm")] #[cfg(target_family = "wasm")]
pub fn create_surface(&self, canvas: graphene_application_io::WasmSurfaceHandle) -> Result<SurfaceHandle<Surface>> { pub fn create_surface(&self, canvas: graphene_application_io::WasmSurfaceHandle) -> Result<SurfaceHandle<Surface>> {
let surface = self.context.instance.create_surface(wgpu::SurfaceTarget::Canvas(canvas.surface))?; let surface = self.context.instance.create_surface(wgpu::SurfaceTarget::Canvas(canvas.surface))?;
@ -196,9 +203,12 @@ impl WgpuExecutor {
.map_err(|e| anyhow::anyhow!("Failed to create Vello renderer: {:?}", e)) .map_err(|e| anyhow::anyhow!("Failed to create Vello renderer: {:?}", e))
.ok()?; .ok()?;
let resampler = Resampler::new(&context.device);
Some(Self { Some(Self {
shader_runtime: ShaderRuntime::new(&context), shader_runtime: ShaderRuntime::new(&context),
context, context,
resampler,
vello_renderer: vello_renderer.into(), vello_renderer: vello_renderer.into(),
}) })
} }

View File

@ -0,0 +1,152 @@
use crate::WgpuContext;
use glam::{DAffine2, UVec2, Vec2};
pub struct Resampler {
pipeline: wgpu::RenderPipeline,
bind_group_layout: wgpu::BindGroupLayout,
}
impl Resampler {
pub fn new(device: &wgpu::Device) -> Self {
let shader = device.create_shader_module(wgpu::include_wgsl!("resample_shader.wgsl"));
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("resample_bind_group_layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
multisampled: false,
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::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("resample_pipeline_layout"),
bind_group_layouts: &[&bind_group_layout],
push_constant_ranges: &[],
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("resample_pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba8Unorm,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview: None,
cache: None,
});
Resampler { pipeline, bind_group_layout }
}
pub fn resample(&self, context: &WgpuContext, source: &wgpu::Texture, target_size: UVec2, transform: &DAffine2) -> wgpu::Texture {
let device = &context.device;
let queue = &context.queue;
let output_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("resample_output"),
size: wgpu::Extent3d {
width: target_size.x.max(1),
height: target_size.y.max(1),
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let source_view = source.create_view(&wgpu::TextureViewDescriptor::default());
let output_view = output_texture.create_view(&wgpu::TextureViewDescriptor::default());
let params_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("resample_params"),
size: 32,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let params_data = [transform.matrix2.x_axis.as_vec2(), transform.matrix2.y_axis.as_vec2(), transform.translation.as_vec2(), Vec2::ZERO];
queue.write_buffer(&params_buffer, 0, bytemuck::cast_slice(&params_data));
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("resample_bind_group"),
layout: &self.bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&source_view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: params_buffer.as_entire_binding(),
},
],
});
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("resample_encoder") });
{
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("resample_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &output_view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
depth_slice: None,
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
render_pass.set_pipeline(&self.pipeline);
render_pass.set_bind_group(0, &bind_group, &[]);
render_pass.draw(0..3, 0..1);
}
queue.submit([encoder.finish()]);
output_texture
}
}

View File

@ -0,0 +1,51 @@
// =============
// VERTEX SHADER
// =============
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) tex_coords: vec2<f32>,
}
@vertex
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
var out: VertexOutput;
let pos = array(
vec2f(-1.0, -1.0),
vec2f(3.0, -1.0),
vec2f(-1.0, 3.0),
);
let xy = pos[vertex_index];
out.clip_position = vec4f(xy, 0.0, 1.0);
let coords = xy / 2. + 0.5;
out.tex_coords = vec2f(coords.x, 1. - coords.y);
return out;
}
// ===============
// FRAGMENT SHADER
// ===============
@group(0) @binding(0)
var t_source: texture_2d<f32>;
struct Params {
matrix: mat2x2<f32>,
translation: vec2<f32>,
_pad: vec2<f32>,
};
// We need to use a uniform buffer for the params because push constants are not supported on web
@group(0) @binding(1)
var<uniform> params: Params;
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let position = params.matrix * in.tex_coords + params.translation;
let texel = vec2<i32>(floor(position));
let texture_size = vec2<i32>(textureDimensions(t_source));
if (texel.x >= 0 && texel.x < texture_size.x && texel.y >= 0 && texel.y < texture_size.y) {
return textureLoad(t_source, texel, 0);
}
return vec4<f32>(0.0);
}

View File

@ -1,4 +1,5 @@
pub mod any; pub mod any;
pub mod pixel_preview;
pub mod render_cache; pub mod render_cache;
pub mod render_node; pub mod render_node;
pub mod text; pub mod text;

View File

@ -0,0 +1,71 @@
use crate::render_node::RenderOutputType;
use core_types::transform::{Footprint, Transform};
use core_types::{CloneVarArgs, Context, Ctx, ExtractAll, OwnedContextImpl};
use glam::{DAffine2, DVec2, UVec2};
use graph_craft::document::value::RenderOutput;
use graph_craft::wasm_application_io::WasmEditorApi;
use graphene_application_io::{ApplicationIo, ImageTexture};
use rendering::{RenderOutputType as RenderOutputTypeRequest, RenderParams};
use vector_types::vector::style::RenderMode;
#[node_macro::node(category(""))]
pub async fn pixel_preview<'a: 'n>(
ctx: impl Ctx + ExtractAll + CloneVarArgs + Sync,
editor_api: &'a WasmEditorApi,
data: impl Node<Context<'static>, Output = RenderOutput> + Send + Sync,
) -> RenderOutput {
let Some(render_params) = ctx.vararg(0).ok().and_then(|v| v.downcast_ref::<RenderParams>()).cloned() else {
log::error!("invalid render params for pixel preview");
let context = OwnedContextImpl::from(ctx).into_context();
return data.eval(context).await;
};
let physical_scale = render_params.scale;
let footprint = *ctx.footprint();
let viewport_zoom = footprint.decompose_scale().x;
if render_params.render_mode != RenderMode::PixelPreview || !matches!(render_params.render_output_type, RenderOutputTypeRequest::Vello) || viewport_zoom <= 1. {
let context = OwnedContextImpl::from(ctx).into_context();
return data.eval(context).await;
}
let physical_resolution = footprint.resolution;
let logical_resolution = physical_resolution.as_dvec2() / physical_scale;
let logical_footprint = Footprint {
resolution: logical_resolution.as_uvec2().max(UVec2::ONE),
..footprint
};
let bounds = logical_footprint.viewport_bounds_in_local_space();
let upstream_min = bounds.start.floor();
let upstream_max = bounds.end.ceil();
let upstream_size = (upstream_max - upstream_min).max(DVec2::ONE);
let upstream_resolution = upstream_size.as_uvec2().max(UVec2::ONE);
let upstream_footprint = Footprint {
transform: DAffine2::from_scale(DVec2::splat(1.0 / physical_scale)) * DAffine2::from_translation(-upstream_min),
resolution: upstream_resolution,
quality: footprint.quality,
};
let new_ctx = OwnedContextImpl::from(ctx).with_footprint(upstream_footprint).with_vararg(Box::new(render_params)).into_context();
let mut result = data.eval(new_ctx).await;
let RenderOutputType::Texture(ref source_texture) = result.data else { return result };
let transform = DAffine2::from_translation(-upstream_min) * footprint.transform.inverse() * DAffine2::from_scale(logical_resolution);
let exec = editor_api.application_io.as_ref().unwrap().gpu_executor().unwrap();
let resampled = exec.resample_texture(&source_texture.texture, physical_resolution, &transform);
result.data = RenderOutputType::Texture(ImageTexture { texture: resampled });
result
.metadata
.apply_transform(footprint.transform * DAffine2::from_translation(upstream_min) * DAffine2::from_scale(DVec2::splat(physical_scale)));
result
}

View File

@ -184,8 +184,7 @@ async fn render<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, edito
// We now replace all transforms which are supposed to be infinite with a transform which covers the entire viewport // We now replace all transforms which are supposed to be infinite with a transform which covers the entire viewport
// See <https://xi.zulipchat.com/#narrow/channel/197075-vello/topic/Full.20screen.20color.2Fgradients/near/538435044> for more detail // See <https://xi.zulipchat.com/#narrow/channel/197075-vello/topic/Full.20screen.20color.2Fgradients/near/538435044> for more detail
let scaled_infinite_transform = vello::kurbo::Affine::scale_non_uniform(physical_resolution.x as f64, physical_resolution.y as f64); let scaled_infinite_transform = vello::kurbo::Affine::scale_non_uniform(physical_resolution.x as f64, physical_resolution.y as f64);
let encoding = scene.encoding_mut(); for transform in scene.encoding_mut().transforms.iter_mut() {
for transform in encoding.transforms.iter_mut() {
if transform.matrix[0] == f32::INFINITY { if transform.matrix[0] == f32::INFINITY {
*transform = vello_encoding::Transform::from_kurbo(&scaled_infinite_transform); *transform = vello_encoding::Transform::from_kurbo(&scaled_infinite_transform);
} }