Extract pipelines from WgpuExecutor into scope-provided pipeline nodes (#4210)

* Implement generic pipeline caching

* Fix WIP

* Fix

* Fix

* Fix

* Fix bench

* Migrations

* Review
This commit is contained in:
Timon
2026-06-19 21:20:25 +00:00
committed by GitHub
parent 3bf32443ed
commit 674b3a213f
29 changed files with 630 additions and 444 deletions

View File

@@ -61,6 +61,7 @@ reqwest = { workspace = true }
image = { workspace = true }
base64 = { workspace = true }
wgpu = { workspace = true }
bytemuck = { workspace = true }
# Optional local dependencies
graphene-canvas-utils = { workspace = true, optional = true }

View File

@@ -1,8 +1,9 @@
pub mod any;
pub mod pixel_preview;
pub mod platform_application_io;
pub mod render_background;
pub mod render_cache;
pub mod render_node;
pub mod render_pixel_preview;
pub mod text;
pub use blending_nodes;
pub use brush_nodes as brush;

View File

@@ -1,71 +0,0 @@
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::application_io::PlatformEditorApi;
use graph_craft::document::value::RenderOutput;
use graphene_application_io::ApplicationIo;
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 PlatformEditorApi,
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.scale_magnitudes().x * physical_scale;
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. / 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.as_ref(), physical_resolution, &transform).await;
result.data = RenderOutputType::Texture(resampled.into());
result
.metadata
.apply_transform(footprint.transform * DAffine2::from_translation(upstream_min) * DAffine2::from_scale(DVec2::splat(physical_scale)));
result
}

View File

@@ -137,7 +137,7 @@ fn image_to_bytes(_: impl Ctx, image: List<Raster<CPU>>) -> List<u8> {
/// Loads binary from URLs and local asset paths. Returns a transparent placeholder if the resource fails to load, allowing rendering to continue.
#[node_macro::node(category("Web Request"))]
async fn load_resource<'a: 'n>(_: impl Ctx, _primary: (), #[scope("editor-api")] _editor: &'a PlatformEditorApi, #[name("URL")] url: String) -> Arc<[u8]> {
async fn load_resource<'a: 'n>(_: impl Ctx, _primary: (), #[name("URL")] url: String) -> Arc<[u8]> {
let placeholder = || -> Arc<[u8]> { Arc::from(Vec::<u8>::new()) };
let response = match reqwest::Client::new().get(&url).send().await {
@@ -261,10 +261,30 @@ where
)
}
#[node_macro::node(category(""), inject_scope)]
pub async fn editor_api<'a: 'n>(_: impl Ctx, #[scope("editor-api")] editor_api: &'a PlatformEditorApi) -> &'a PlatformEditorApi {
editor_api
}
#[node_macro::node(category(""))]
pub async fn resource<'a: 'n>(_: impl Ctx, hash: ResourceHash, #[scope("editor-api")] editor_api: &'a PlatformEditorApi) -> Resource {
pub async fn resource<'a: 'n>(_: impl Ctx, hash: ResourceHash, #[scope(editor_api::IDENTIFIER)] editor_api: &'a PlatformEditorApi) -> Resource {
let application_io = editor_api.application_io.as_ref().expect("ApplicationIo must be available when using resources");
application_io.load_resource(hash).await.unwrap_or_else(|| {
panic!("Resource {hash} not found");
})
}
#[node_macro::node(category(""), inject_scope)]
pub async fn wgpu_executor<'a: 'n>(_: impl Ctx, #[scope(editor_api::IDENTIFIER)] editor_api: &'a PlatformEditorApi) -> &'a ::wgpu_executor::WgpuExecutor {
editor_api
.application_io
.as_ref()
.expect("ApplicationIo not not available")
.gpu_executor()
.expect("GPU executor not available")
}
#[node_macro::node(category(""), inject_scope)]
pub async fn try_wgpu_executor<'a: 'n>(_: impl Ctx, #[scope(editor_api::IDENTIFIER)] editor_api: &'a PlatformEditorApi) -> Option<&'a ::wgpu_executor::WgpuExecutor> {
editor_api.application_io.as_ref()?.gpu_executor()
}

View File

@@ -0,0 +1,497 @@
use core_types::ExtractVarArgs;
use core_types::color::Linear;
use core_types::transform::Footprint;
use core_types::uuid::generate_uuid;
use core_types::{Ctx, ExtractFootprint};
use glam::{Affine2, UVec2, Vec2};
use graph_craft::document::value::{RenderOutput, RenderOutputType};
use rendering::{RenderParams, SvgRender, SvgRenderOutput};
use std::fmt::Write;
use std::sync::Arc;
use wgpu::util::DeviceExt;
use wgpu_executor::{AsyncWgpuPipeline, WgpuExecutor, WgpuPipelineCache};
#[node_macro::node(category(""))]
async fn render_background<'a: 'n>(
ctx: impl Ctx + ExtractFootprint + ExtractVarArgs,
#[scope(composite_background_pipeline::IDENTIFIER)] pipeline: WgpuPipelineCache,
data: RenderOutput,
) -> RenderOutput {
let footprint = ctx.footprint();
let render_params = ctx
.vararg(0)
.expect("Did not find var args")
.downcast_ref::<RenderParams>()
.expect("Downcasting render params yielded invalid type");
if !render_params.to_canvas() || render_params.viewport_zoom <= 0.0 {
return data;
}
let RenderOutput { data: foreground_data, metadata } = data;
let mut render_params = render_params.clone();
render_params.footprint = *footprint;
let data = match foreground_data {
RenderOutputType::Texture(foreground_texture) => {
let doc_to_screen = (glam::DAffine2::from_scale(glam::DVec2::splat(render_params.scale)) * render_params.footprint.transform).as_affine2();
let blended = pipeline
.run::<CompositeBackground>(&CompositeBackgroundArgs {
foreground: foreground_texture.as_ref(),
backgrounds: &metadata.backgrounds,
document_to_screen: doc_to_screen,
zoom: render_params.viewport_zoom.to_f32(),
})
.await;
RenderOutputType::Texture(blended.into())
}
RenderOutputType::Svg {
svg: foreground_svg,
image_data: foreground_images,
} => {
let mut render = SvgRender::new();
if render_params.viewport_zoom > 0. {
let draw_checkerboard = |render: &mut SvgRender, rect: vello::kurbo::Rect, pattern_origin: glam::DVec2, checker_id_prefix: &str| {
let checker_id = format!("{checker_id_prefix}-{}", generate_uuid());
let cell_size = 8. / render_params.viewport_zoom;
let pattern_size = cell_size * 2.;
write!(
&mut render.svg_defs,
r##"<pattern id="{checker_id}" x="{}" y="{}" width="{pattern_size}" height="{pattern_size}" patternUnits="userSpaceOnUse"><rect width="{pattern_size}" height="{pattern_size}" fill="#ffffff" /><rect x="{cell_size}" y="0" width="{cell_size}" height="{cell_size}" fill="#cccccc" /><rect x="0" y="{cell_size}" width="{cell_size}" height="{cell_size}" fill="#cccccc" /></pattern>"##,
pattern_origin.x,
pattern_origin.y,
)
.unwrap();
render.leaf_tag("rect", |attributes| {
attributes.push("x", rect.x0.to_string());
attributes.push("y", rect.y0.to_string());
attributes.push("width", rect.width().to_string());
attributes.push("height", rect.height().to_string());
attributes.push("fill", format!("url(#{checker_id})"));
});
};
if metadata.backgrounds.is_empty() {
if render_params.scale > 0. {
let logical_resolution = render_params.footprint.resolution.as_dvec2() / render_params.scale;
let logical_footprint = Footprint {
resolution: logical_resolution.round().as_uvec2().max(glam::UVec2::ONE),
..render_params.footprint
};
let bounds = logical_footprint.viewport_bounds_in_local_space();
let min = bounds.start.floor();
let max = bounds.end.ceil();
if min.is_finite() && max.is_finite() {
let rect = vello::kurbo::Rect::new(min.x, min.y, max.x, max.y);
draw_checkerboard(&mut render, rect, glam::DVec2::ZERO, "checkered-viewport");
}
}
} else {
for background in &metadata.backgrounds {
let [a, b] = [background.location, background.location + background.dimensions];
let rect = vello::kurbo::Rect::new(a.x.min(b.x), a.y.min(b.y), a.x.max(b.x), a.y.max(b.y));
draw_checkerboard(&mut render, rect, glam::DVec2::new(rect.x0, rect.y0), "checkered-artboard");
}
}
}
let logical_resolution = render_params.footprint.resolution.as_dvec2() / render_params.scale;
render.wrap_with_transform(render_params.footprint.transform, Some(logical_resolution));
let background = SvgRenderOutput::from(render);
assert!(background.svg_defs.is_empty());
let svg = format!("{}{}", background.svg, foreground_svg);
let image_data = foreground_images;
RenderOutputType::Svg { svg, image_data }
}
_ => unreachable!("Render background node received unsupported render output type"),
};
RenderOutput { data, metadata }
}
#[node_macro::node(category(""), inject_scope)]
async fn composite_background_pipeline<'a: 'n>(
_ctx: impl Ctx,
#[scope(crate::platform_application_io::try_wgpu_executor::IDENTIFIER)] executor: Option<&'a WgpuExecutor>,
#[data] pipeline: WgpuPipelineCache,
) -> WgpuPipelineCache {
if let Some(executor) = executor {
executor.pipeline_init::<CompositeBackground>(pipeline);
}
pipeline.clone()
}
pub struct CompositeBackground {
checker_rect_pipeline: wgpu::RenderPipeline,
checker_viewport_pipeline: wgpu::RenderPipeline,
fullscreen_pipeline: wgpu::RenderPipeline,
checker_bind_group_layout: wgpu::BindGroupLayout,
fullscreen_bind_group_layout: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
}
pub struct CompositeBackgroundArgs<'a> {
foreground: &'a wgpu::Texture,
backgrounds: &'a [rendering::Background],
document_to_screen: Affine2,
zoom: f32,
}
impl AsyncWgpuPipeline for CompositeBackground {
type Args<'a> = CompositeBackgroundArgs<'a>;
type Out = Arc<wgpu::Texture>;
fn create(executor: &WgpuExecutor) -> Self {
let device = &executor.context().device;
let format = wgpu::TextureFormat::Rgba8Unorm;
let checker_rect_shader = device.create_shader_module(wgpu::include_wgsl!("render_background_checker_rect.wgsl"));
let checker_viewport_shader = device.create_shader_module(wgpu::include_wgsl!("render_background_checker_viewport.wgsl"));
let fullscreen_shader = device.create_shader_module(wgpu::include_wgsl!("render_background_fullscreen.wgsl"));
let checker_bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("background_checker_bind_group_layout"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let checker_rect_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("background_checker_rect_pipeline_layout"),
bind_group_layouts: &[Some(&checker_bind_group_layout)],
immediate_size: 0,
});
let checker_viewport_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("background_checker_viewport_pipeline_layout"),
bind_group_layouts: &[Some(&checker_bind_group_layout)],
immediate_size: 0,
});
let fullscreen_bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("background_fullscreen_bind_group_layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
multisampled: false,
view_dimension: wgpu::TextureViewDimension::D2,
sample_type: wgpu::TextureSampleType::Float { filterable: true },
},
count: None,
},
],
});
let fullscreen_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("background_fullscreen_pipeline_layout"),
bind_group_layouts: &[Some(&fullscreen_bind_group_layout)],
immediate_size: 0,
});
let checker_rect_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("background_checker_rect_pipeline"),
layout: Some(&checker_rect_pipeline_layout),
vertex: wgpu::VertexState {
module: &checker_rect_shader,
entry_point: Some("vs_main"),
compilation_options: Default::default(),
buffers: &[],
},
fragment: Some(wgpu::FragmentState {
module: &checker_rect_shader,
entry_point: Some("fs_main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
cache: None,
});
let checker_viewport_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("background_checker_viewport_pipeline"),
layout: Some(&checker_viewport_pipeline_layout),
vertex: wgpu::VertexState {
module: &checker_viewport_shader,
entry_point: Some("vs_main"),
compilation_options: Default::default(),
buffers: &[],
},
fragment: Some(wgpu::FragmentState {
module: &checker_viewport_shader,
entry_point: Some("fs_main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
cache: None,
});
let fullscreen_blend = wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::SrcAlpha,
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
operation: wgpu::BlendOperation::Add,
},
};
let fullscreen_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("background_fullscreen_pipeline"),
layout: Some(&fullscreen_pipeline_layout),
vertex: wgpu::VertexState {
module: &fullscreen_shader,
entry_point: Some("vs_main"),
compilation_options: Default::default(),
buffers: &[],
},
fragment: Some(wgpu::FragmentState {
module: &fullscreen_shader,
entry_point: Some("fs_main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(fullscreen_blend),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview_mask: None,
cache: None,
});
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("background_fullscreen_sampler"),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
..Default::default()
});
Self {
checker_rect_pipeline,
checker_viewport_pipeline,
fullscreen_pipeline,
checker_bind_group_layout,
fullscreen_bind_group_layout,
sampler,
}
}
async fn run<'a>(&'a self, executor: &'a WgpuExecutor, args: &'a Self::Args<'_>) -> Self::Out {
let &CompositeBackgroundArgs {
foreground,
backgrounds,
document_to_screen,
zoom,
} = args;
let foreground_size = foreground.size();
let output = executor.request_texture(UVec2::new(foreground_size.width, foreground_size.height)).await;
if zoom <= 0. {
return output;
}
let device = &executor.context().device;
let queue = &executor.context().queue;
let checker_size_doc = 8. / zoom;
let screen_to_document = document_to_screen.inverse();
let viewport_size = output.size();
let viewport_size = Vec2::new(viewport_size.width as f32, viewport_size.height as f32);
let output_view = output.create_view(&wgpu::TextureViewDescriptor::default());
let foreground_view = foreground.create_view(&wgpu::TextureViewDescriptor::default());
let checker_draws = if backgrounds.is_empty() {
vec![(
3,
self.create_checker_bind_group(device, CompositeUniforms::fullscreen(viewport_size, screen_to_document, checker_size_doc)),
)]
} else {
backgrounds
.iter()
.filter_map(|background| {
let a = background.location.as_vec2();
let b = (background.location + background.dimensions).as_vec2();
let min = a.min(b);
let max = a.max(b);
if max.x <= min.x || max.y <= min.y {
return None;
}
let uniforms = CompositeUniforms::rect(min, max, document_to_screen, viewport_size, checker_size_doc);
Some((6, self.create_checker_bind_group(device, uniforms)))
})
.collect()
};
let fullscreen_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("background_fullscreen_bind_group"),
layout: &self.fullscreen_bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&foreground_view),
},
],
});
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("background_encoder") });
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("background_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,
multiview_mask: None,
});
if backgrounds.is_empty() {
pass.set_pipeline(&self.checker_viewport_pipeline);
for (vertex_count, bind_group) in &checker_draws {
pass.set_bind_group(0, bind_group, &[]);
pass.draw(0..*vertex_count, 0..1);
}
} else {
pass.set_pipeline(&self.checker_rect_pipeline);
for (vertex_count, bind_group) in &checker_draws {
pass.set_bind_group(0, bind_group, &[]);
pass.draw(0..*vertex_count, 0..1);
}
}
pass.set_pipeline(&self.fullscreen_pipeline);
pass.set_bind_group(0, &fullscreen_bind_group, &[]);
pass.draw(0..3, 0..1);
}
queue.submit(std::iter::once(encoder.finish()));
output
}
}
impl CompositeBackground {
fn create_checker_bind_group(&self, device: &wgpu::Device, uniforms: CompositeUniforms) -> wgpu::BindGroup {
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("background_checker_uniforms"),
contents: bytemuck::bytes_of(&uniforms),
usage: wgpu::BufferUsages::UNIFORM,
});
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("background_checker_bind_group"),
layout: &self.checker_bind_group_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
}],
})
}
}
#[repr(C)]
#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)]
struct CompositeUniforms {
transform_x: [f32; 2],
transform_y: [f32; 2],
transform_translation: [f32; 2],
rect_min: [f32; 2],
rect_max: [f32; 2],
viewport_size: [f32; 2],
pattern_origin: [f32; 2],
checker_size: f32,
_pad: f32,
}
impl CompositeUniforms {
fn fullscreen(viewport_size: Vec2, screen_to_document: Affine2, checker_size_doc: f32) -> Self {
Self::new(screen_to_document, Vec2::ZERO, Vec2::ZERO, viewport_size, Vec2::ZERO, checker_size_doc)
}
fn rect(rect_min: Vec2, rect_max: Vec2, document_to_screen: Affine2, viewport_size: Vec2, checker_size_doc: f32) -> Self {
Self::new(document_to_screen, rect_min, rect_max, viewport_size, rect_min, checker_size_doc)
}
fn new(transform: Affine2, rect_min: Vec2, rect_max: Vec2, viewport_size: Vec2, pattern_origin: Vec2, checker_size: f32) -> Self {
Self {
transform_x: transform.matrix2.x_axis.to_array(),
transform_y: transform.matrix2.y_axis.to_array(),
transform_translation: transform.translation.to_array(),
rect_min: rect_min.to_array(),
rect_max: rect_max.to_array(),
viewport_size: viewport_size.to_array(),
pattern_origin: pattern_origin.to_array(),
checker_size,
_pad: 0.,
}
}
}

View File

@@ -0,0 +1,56 @@
struct CompositeUniforms {
transform_x: vec2<f32>,
transform_y: vec2<f32>,
transform_translation: vec2<f32>,
rect_min: vec2<f32>,
rect_max: vec2<f32>,
viewport_size: vec2<f32>,
pattern_origin: vec2<f32>,
checker_size: f32,
_pad: f32,
};
@group(0) @binding(0)
var<uniform> uniforms: CompositeUniforms;
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) document_position: vec2<f32>,
};
@vertex
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
let document_corners = array<vec2<f32>, 6>(
uniforms.rect_min,
vec2<f32>(uniforms.rect_max.x, uniforms.rect_min.y),
vec2<f32>(uniforms.rect_min.x, uniforms.rect_max.y),
vec2<f32>(uniforms.rect_min.x, uniforms.rect_max.y),
vec2<f32>(uniforms.rect_max.x, uniforms.rect_min.y),
uniforms.rect_max,
);
let document_position = document_corners[vertex_index];
let transformed = uniforms.transform_x * document_position.x + uniforms.transform_y * document_position.y + uniforms.transform_translation;
let normalized = transformed / uniforms.viewport_size;
let clip = vec2<f32>(normalized.x * 2.0 - 1.0, 1.0 - normalized.y * 2.0);
var out: VertexOutput;
out.position = vec4<f32>(clip, 0.0, 1.0);
out.document_position = document_position;
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let tile = floor((in.document_position - uniforms.pattern_origin) / uniforms.checker_size);
let parity = i32(tile.x + tile.y) & 1;
let luminance = select(1.0, 0.8, parity == 1);
let fw = fwidthFine(in.document_position);
let coverage_max = 1.0 - smoothstep(uniforms.rect_max - fw, uniforms.rect_max, in.document_position);
let coverage_min = smoothstep(uniforms.rect_min, uniforms.rect_min + fw, in.document_position);
let coverage = coverage_max * coverage_min;
let alpha = coverage.x * coverage.y;
return vec4<f32>(vec3<f32>(luminance), alpha);
}

View File

@@ -0,0 +1,45 @@
struct CompositeUniforms {
transform_x: vec2<f32>,
transform_y: vec2<f32>,
transform_translation: vec2<f32>,
rect_min: vec2<f32>,
rect_max: vec2<f32>,
viewport_size: vec2<f32>,
pattern_origin: vec2<f32>,
checker_size: f32,
_pad: f32,
};
@group(0) @binding(0)
var<uniform> uniforms: CompositeUniforms;
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) document_position: vec2<f32>,
};
@vertex
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
let positions = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0),
vec2<f32>(-1.0, 3.0),
vec2<f32>( 3.0, -1.0),
);
let position = positions[vertex_index];
let screen_position = vec2<f32>((position.x + 1.0) * 0.5 * uniforms.viewport_size.x, (1.0 - position.y) * 0.5 * uniforms.viewport_size.y);
let document_position = uniforms.transform_x * screen_position.x + uniforms.transform_y * screen_position.y + uniforms.transform_translation;
var out: VertexOutput;
out.position = vec4<f32>(position, 0.0, 1.0);
out.document_position = document_position;
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let tile = floor((in.document_position - uniforms.pattern_origin) / uniforms.checker_size);
let parity = i32(tile.x + tile.y) & 1;
let luminance = vec3<f32>(select(1.0, 0.8, parity == 1));
return vec4<f32>(luminance, 1.0);
}

View File

@@ -0,0 +1,35 @@
@group(0) @binding(0)
var foreground_sampler: sampler;
@group(0) @binding(1)
var foreground_texture: texture_2d<f32>;
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) tex_coord: vec2<f32>,
};
@vertex
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
let positions = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0),
vec2<f32>(-1.0, 3.0),
vec2<f32>( 3.0, -1.0),
);
let tex_coords = array<vec2<f32>, 3>(
vec2<f32>(0.0, 1.0),
vec2<f32>(0.0, -1.0),
vec2<f32>(2.0, 1.0),
);
var vertex_out: VertexOutput;
vertex_out.position = vec4<f32>(positions[vertex_index], 0.0, 1.0);
vertex_out.tex_coord = tex_coords[vertex_index];
return vertex_out;
}
@fragment
fn fs_main(fragment_in: VertexOutput) -> @location(0) vec4<f32> {
return textureSample(foreground_texture, foreground_sampler, fragment_in.tex_coord);
}

View File

@@ -5,14 +5,13 @@ use core_types::transform::{Footprint, RenderQuality, Transform};
use core_types::{CloneVarArgs, Context, Ctx, ExtractAll, ExtractAnimationTime, ExtractPointerPosition, ExtractRealTime, OwnedContextImpl};
use glam::{DAffine2, DVec2, IVec2, UVec2};
use graph_craft::application_io::PlatformEditorApi;
use graph_craft::document::value::RenderOutput;
use graphene_application_io::{ApplicationIo, ImageTexture};
use graph_craft::document::value::{RenderOutput, RenderOutputType};
use graphene_application_io::ImageTexture;
use rendering::{RenderOutputType as RenderOutputTypeRequest, RenderParams};
use std::collections::HashSet;
use std::hash::Hash;
use std::sync::{Arc, Mutex};
use crate::render_node::RenderOutputType;
use wgpu_executor::WgpuExecutor;
pub const TILE_SIZE: u32 = 256;
pub const MAX_CACHE_MEMORY_BYTES: usize = 512 * 1024 * 1024;
@@ -327,7 +326,8 @@ fn flood_fill(start: &TileCoord, tile_set: &HashSet<TileCoord>, visited: &mut Ha
#[node_macro::node(category(""))]
pub async fn render_output_cache<'a: 'n>(
ctx: impl Ctx + ExtractAll + CloneVarArgs + ExtractRealTime + ExtractAnimationTime + ExtractPointerPosition + Sync,
editor_api: &'a PlatformEditorApi,
#[scope(crate::platform_application_io::try_wgpu_executor::IDENTIFIER)] executor: Option<&'a WgpuExecutor>,
#[scope(crate::platform_application_io::editor_api::IDENTIFIER)] editor_api: &'a PlatformEditorApi,
data: impl Node<Context<'static>, Output = RenderOutput> + Send + Sync,
#[data] tile_cache: TileCache,
) -> RenderOutput {
@@ -404,11 +404,9 @@ pub async fn render_output_cache<'a: 'n>(
return data.eval(context.into_context()).await;
}
let exec = editor_api.application_io.as_ref().unwrap().gpu_executor().unwrap();
let output_texture = exec.request_texture(physical_resolution).await;
let combined_metadata = composite_cached_regions(&all_regions, &output_texture, &device_origin_offset, &footprint.transform, exec);
let executor = executor.expect("GPU executor not available");
let output_texture = executor.request_texture(physical_resolution).await;
let combined_metadata = composite_cached_regions(&all_regions, &output_texture, &device_origin_offset, &footprint.transform, &executor);
RenderOutput {
data: RenderOutputType::Texture(output_texture.into()),
@@ -473,8 +471,8 @@ fn composite_cached_regions(
viewport_transform: &DAffine2,
exec: &wgpu_executor::WgpuExecutor,
) -> rendering::RenderMetadata {
let device = &exec.context.device;
let queue = &exec.context.queue;
let device = &exec.context().device;
let queue = &exec.context().queue;
let output_resolution = UVec2::new(output_texture.width(), output_texture.height());
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("composite") });

View File

@@ -1,22 +1,15 @@
use core_types::list::List;
use core_types::transform::{Footprint, Transform};
use core_types::uuid::generate_uuid;
use core_types::{CloneVarArgs, ExtractAll, ExtractVarArgs};
use core_types::{Color, Context, Ctx, ExtractFootprint, OwnedContextImpl, WasmNotSend};
use graph_craft::application_io::PlatformEditorApi;
use graph_craft::document::value::RenderOutput;
pub use graph_craft::document::value::RenderOutputType;
use graphene_application_io::{ApplicationIo, ExportFormat, RenderConfig};
use graph_craft::document::value::{RenderOutput, RenderOutputType};
use graphene_application_io::{ExportFormat, RenderConfig};
use graphic_types::raster_types::{CPU, Raster};
use graphic_types::{Artboard, Graphic, Vector};
use rendering::{Render, RenderMetadata, RenderOutputType as RenderOutputTypeRequest, RenderParams, SvgRender, SvgRenderOutput};
use std::fmt::Write;
use std::sync::Arc;
use vector_types::GradientStops;
use wgpu_executor::RenderContext;
// Re-export render_output_cache from render_cache module
pub use crate::render_cache::render_output_cache;
use wgpu_executor::{RenderContext, WgpuExecutor};
#[derive(Clone, dyn_any::DynAny)]
pub enum RenderIntermediateType {
@@ -81,7 +74,11 @@ async fn render_intermediate<'a: 'n, T: 'static + Render + WasmNotSend + Send +
}
#[node_macro::node(category(""))]
async fn render<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, editor_api: &'a PlatformEditorApi, data: RenderIntermediate) -> RenderOutput {
async fn render<'a: 'n>(
ctx: impl Ctx + ExtractFootprint + ExtractVarArgs,
#[scope(crate::platform_application_io::try_wgpu_executor::IDENTIFIER)] executor: Option<&'a WgpuExecutor>,
data: RenderIntermediate,
) -> RenderOutput {
let footprint = ctx.footprint();
let render_params = ctx
.vararg(0)
@@ -110,9 +107,6 @@ async fn render<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, edito
}
}
(RenderOutputTypeRequest::Vello, RenderIntermediateType::Vello(data)) => {
let Some(exec) = editor_api.application_io.as_ref().unwrap().gpu_executor() else {
unreachable!("Attempted to render with Vello when no GPU executor is available");
};
let (scene, context) = data.as_ref();
let scale = render_params.scale;
let physical_resolution = render_params.footprint.resolution;
@@ -139,7 +133,8 @@ async fn render<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, edito
}
}
let texture = exec
let texture = executor
.expect("GPU executor not available")
.render_vello_scene(&transformed_scene, physical_resolution, context, None)
.await
.expect("Failed to render Vello scene");
@@ -151,107 +146,6 @@ async fn render<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, edito
RenderOutput { data, metadata }
}
#[node_macro::node(category(""))]
async fn render_background<'a: 'n>(ctx: impl Ctx + ExtractFootprint + ExtractVarArgs, editor_api: &'a PlatformEditorApi, data: RenderOutput) -> RenderOutput {
let footprint = ctx.footprint();
let render_params = ctx
.vararg(0)
.expect("Did not find var args")
.downcast_ref::<RenderParams>()
.expect("Downcasting render params yielded invalid type");
if !render_params.to_canvas() {
return data;
}
let RenderOutput { data: foreground_data, metadata } = data;
let mut render_params = render_params.clone();
render_params.footprint = *footprint;
let data = match foreground_data {
RenderOutputType::Texture(foreground_texture) => {
if let Some(exec) = editor_api.application_io.as_ref().unwrap().gpu_executor() {
let doc_to_screen = (glam::DAffine2::from_scale(glam::DVec2::splat(render_params.scale)) * render_params.footprint.transform).as_affine2();
let blended = exec
.composite_background(foreground_texture.as_ref(), &metadata.backgrounds, doc_to_screen, render_params.viewport_zoom as f32)
.await;
RenderOutputType::Texture(blended.into())
} else {
RenderOutputType::Texture(foreground_texture)
}
}
RenderOutputType::Svg {
svg: foreground_svg,
image_data: foreground_images,
} => {
let mut render = SvgRender::new();
if render_params.viewport_zoom > 0. {
let draw_checkerboard = |render: &mut SvgRender, rect: vello::kurbo::Rect, pattern_origin: glam::DVec2, checker_id_prefix: &str| {
let checker_id = format!("{checker_id_prefix}-{}", generate_uuid());
let cell_size = 8. / render_params.viewport_zoom;
let pattern_size = cell_size * 2.;
write!(
&mut render.svg_defs,
r##"<pattern id="{checker_id}" x="{}" y="{}" width="{pattern_size}" height="{pattern_size}" patternUnits="userSpaceOnUse"><rect width="{pattern_size}" height="{pattern_size}" fill="#ffffff" /><rect x="{cell_size}" y="0" width="{cell_size}" height="{cell_size}" fill="#cccccc" /><rect x="0" y="{cell_size}" width="{cell_size}" height="{cell_size}" fill="#cccccc" /></pattern>"##,
pattern_origin.x,
pattern_origin.y,
)
.unwrap();
render.leaf_tag("rect", |attributes| {
attributes.push("x", rect.x0.to_string());
attributes.push("y", rect.y0.to_string());
attributes.push("width", rect.width().to_string());
attributes.push("height", rect.height().to_string());
attributes.push("fill", format!("url(#{checker_id})"));
});
};
if metadata.backgrounds.is_empty() {
if render_params.scale > 0. {
let logical_resolution = render_params.footprint.resolution.as_dvec2() / render_params.scale;
let logical_footprint = Footprint {
resolution: logical_resolution.round().as_uvec2().max(glam::UVec2::ONE),
..render_params.footprint
};
let bounds = logical_footprint.viewport_bounds_in_local_space();
let min = bounds.start.floor();
let max = bounds.end.ceil();
if min.is_finite() && max.is_finite() {
let rect = vello::kurbo::Rect::new(min.x, min.y, max.x, max.y);
draw_checkerboard(&mut render, rect, glam::DVec2::ZERO, "checkered-viewport");
}
}
} else {
for background in &metadata.backgrounds {
let [a, b] = [background.location, background.location + background.dimensions];
let rect = vello::kurbo::Rect::new(a.x.min(b.x), a.y.min(b.y), a.x.max(b.x), a.y.max(b.y));
draw_checkerboard(&mut render, rect, glam::DVec2::new(rect.x0, rect.y0), "checkered-artboard");
}
}
}
let logical_resolution = render_params.footprint.resolution.as_dvec2() / render_params.scale;
render.wrap_with_transform(render_params.footprint.transform, Some(logical_resolution));
let background = SvgRenderOutput::from(render);
assert!(background.svg_defs.is_empty());
let svg = format!("{}{}", background.svg, foreground_svg);
let image_data = foreground_images;
RenderOutputType::Svg { svg, image_data }
}
_ => unreachable!("Render background node received unsupported render output type"),
};
RenderOutput { data, metadata }
}
#[node_macro::node(category(""))]
async fn create_context<'a: 'n>(
// Context injections are defined in the wrap_network_in_scope function

View File

@@ -0,0 +1,232 @@
use core_types::transform::{Footprint, Transform};
use core_types::{CloneVarArgs, Context, Ctx, ExtractAll, OwnedContextImpl};
use glam::{DAffine2, DVec2, UVec2, Vec2};
use graph_craft::document::value::{RenderOutput, RenderOutputType};
use rendering::{RenderOutputType as RenderOutputTypeRequest, RenderParams};
use std::sync::Arc;
use vector_types::vector::style::RenderMode;
use wgpu_executor::{AsyncWgpuPipeline, WgpuExecutor, WgpuPipelineCache};
#[node_macro::node(category(""))]
pub async fn render_pixel_preview<'a: 'n>(
ctx: impl Ctx + ExtractAll + CloneVarArgs + Sync,
#[scope(pixel_preview_pipeline::IDENTIFIER)] pipeline: WgpuPipelineCache,
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.scale_magnitudes().x * physical_scale;
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. / 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 resampled = pipeline
.run::<PixelPreview>(&PixelPreviewArgs {
source: source_texture.as_ref(),
transform: &transform,
size: physical_resolution,
})
.await;
result.data = RenderOutputType::Texture(resampled.into());
result
.metadata
.apply_transform(footprint.transform * DAffine2::from_translation(upstream_min) * DAffine2::from_scale(DVec2::splat(physical_scale)));
result
}
#[node_macro::node(category(""), inject_scope)]
async fn pixel_preview_pipeline<'a: 'n>(
_ctx: impl Ctx,
#[scope(crate::platform_application_io::try_wgpu_executor::IDENTIFIER)] executor: Option<&'a WgpuExecutor>,
#[data] pipeline: WgpuPipelineCache,
) -> WgpuPipelineCache {
if let Some(executor) = executor {
executor.pipeline_init::<PixelPreview>(pipeline);
}
pipeline.clone()
}
pub struct PixelPreview {
pipeline: wgpu::RenderPipeline,
bind_group_layout: wgpu::BindGroupLayout,
}
pub struct PixelPreviewArgs<'a> {
source: &'a wgpu::Texture,
transform: &'a DAffine2,
size: UVec2,
}
impl AsyncWgpuPipeline for PixelPreview {
type Args<'a> = PixelPreviewArgs<'a>;
type Out = Arc<wgpu::Texture>;
fn create(executor: &WgpuExecutor) -> Self {
let device = &executor.context().device;
let shader = device.create_shader_module(wgpu::include_wgsl!("render_pixel_preview.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: &[Some(&bind_group_layout)],
..Default::default()
});
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_mask: None,
cache: None,
});
PixelPreview { pipeline, bind_group_layout }
}
async fn run<'a>(&'a self, executor: &'a WgpuExecutor, args: &'a Self::Args<'_>) -> Self::Out {
let context = &executor.context();
let &PixelPreviewArgs { source, transform, size } = args;
let output = executor.request_texture(size).await;
let source_view = source.create_view(&wgpu::TextureViewDescriptor::default());
let output_view = output.create_view(&wgpu::TextureViewDescriptor::default());
let params_buffer = context.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];
context.queue.write_buffer(&params_buffer, 0, bytemuck::cast_slice(&params_data));
let bind_group = context.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 = context.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,
})],
..Default::default()
});
render_pass.set_pipeline(&self.pipeline);
render_pass.set_bind_group(0, &bind_group, &[]);
render_pass.draw(0..3, 0..1);
}
context.queue.submit([encoder.finish()]);
output
}
}

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);
}