pub mod context;
pub mod dab_record;
pub mod extent;
pub mod intrinsics;
pub mod type_system;
pub use context::{CompileWgslCtx, InputBinding, NodeWgsl, ShaderMode};
pub use dab_record::{
intrinsic_dab_header, pack_intrinsic_dab_header, INTRINSIC_DAB_HEADER_FIELDS,
};
pub use extent::{ExtentContribution, ExtentCtx};
pub use intrinsics::{pack_intrinsic_uniforms, IntrinsicUniforms, INTRINSIC_UNIFORMS_SIZE};
pub use type_system::{DabField, DabPacker, UniformField, UniformPacker, ValuePacker, WgslType};
use std::collections::{HashMap, HashSet};
use crate::brush::eval::BrushNodeEvaluator;
use crate::brush::wire::{BrushWireType, ScalarValue};
use crate::nodegraph::{ExecutionPlan, NodeId, PortDir, PortRef};
use self::dab_record::EPS_RADIUS_TARGET_PX;
use self::extent::compose_brush_extent;
use self::type_system::{compute_struct_size, compute_struct_size_for_uniforms};
const EPS_BBOX_CANVAS_PX: f32 = 1e-3;
#[derive(Clone)]
pub struct CompiledBrush {
pub stroke_wgsl: String,
pub cursor_preview_wgsl: String,
pub dab_layout: Vec<DabField>,
pub dab_record_size: usize,
pub uniform_layout: Vec<UniformField>,
pub uniform_size: usize,
pub topology_hash: u64,
pub brush_extent_factor: f32,
pub brush_extent_extra_px: f32,
pub graph_texture_names: Vec<String>,
}
impl std::fmt::Debug for CompiledBrush {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CompiledBrush")
.field("stroke_wgsl_bytes", &self.stroke_wgsl.len())
.field("cursor_preview_wgsl_bytes", &self.cursor_preview_wgsl.len())
.field("dab_record_size", &self.dab_record_size)
.field("uniform_size", &self.uniform_size)
.field("topology_hash", &self.topology_hash)
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone)]
pub enum CompileError {
NodeNotCompilable { type_id: String, reason: String },
NoTerminal,
}
impl std::fmt::Display for CompileError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NodeNotCompilable { type_id, reason } => {
write!(f, "node `{type_id}` is not WGSL-compilable: {reason}")
}
Self::NoTerminal => {
write!(f, "graph has no terminal node (nothing to render)")
}
}
}
}
impl std::error::Error for CompileError {}
pub fn compile_brush_to_wgsl(
graph: &crate::nodegraph::Graph<BrushWireType>,
plan: &ExecutionPlan,
evaluators: &HashMap<String, Box<dyn BrushNodeEvaluator>>,
) -> Result<CompiledBrush, CompileError> {
if plan.steps.is_empty() {
return Err(CompileError::NoTerminal);
}
let preview_graph = {
let mut g = graph.clone();
g.apply_preview_overrides();
g
};
let mut decls = String::new();
let mut shared_body = String::new();
let mut stroke_terminal_body = String::new();
let mut preview_terminal_body = String::new();
let mut dab_fields = intrinsic_dab_header();
let mut uniform_fields: Vec<UniformField> = Vec::new();
let mut terminal_bindings = String::new();
let graph_textures_cell: std::cell::RefCell<Vec<String>> = std::cell::RefCell::new(Vec::new());
let mut output_exprs: HashMap<PortRef, String> = HashMap::new();
let mut slot_to_port: HashMap<usize, PortRef> = HashMap::new();
let consumed_sources: HashSet<PortRef> = plan
.steps
.iter()
.flat_map(|s| s.input_slots.iter())
.map(|sl| sl.source.clone())
.collect();
for step in &plan.steps {
let evaluator =
evaluators
.get(&step.type_id)
.ok_or_else(|| CompileError::NodeNotCompilable {
type_id: step.type_id.clone(),
reason: "no evaluator registered".into(),
})?;
let mut inputs: HashMap<String, InputBinding> = HashMap::new();
let node = graph
.nodes()
.get(&step.node_id)
.expect("plan step references existing node");
for slot_info in &step.input_slots {
let src_port = slot_to_port.get(&slot_info.slot).cloned().or_else(|| {
output_exprs
.keys()
.find(|pr| **pr == slot_info.source)
.cloned()
});
let Some(src_port) = src_port else {
continue;
};
let Some(expr) = output_exprs.get(&src_port).cloned() else {
continue;
};
let remapped =
apply_wire_remap(expr, &src_port, step.node_id, &slot_info.port_name, graph);
inputs.insert(slot_info.port_name.clone(), InputBinding::Wired(remapped));
}
let lut: Option<crate::brush::curve_math::CurveLut> =
node.params.iter().find_map(|p| match p {
crate::gpu::params::ParamValue::Curve(pts) if pts.len() >= 2 => {
Some(crate::brush::curve_math::CurveLut::from_points(pts))
}
_ => None,
});
let consumed_outputs: HashSet<String> = consumed_sources
.iter()
.filter(|pr| pr.node == step.node_id)
.map(|pr| pr.port.clone())
.collect();
let preview_cctx_parts = if step.is_terminal {
let preview_node = preview_graph
.nodes()
.get(&step.node_id)
.expect("preview-graph clone has the same node set as the original");
let preview_inputs: HashMap<String, InputBinding> = inputs
.iter()
.filter(|(port_name, _)| {
preview_graph
.connections
.iter()
.any(|c| c.to.node == step.node_id && &c.to.port == *port_name)
})
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
Some((preview_node, preview_inputs, consumed_outputs.clone()))
} else {
None
};
let cctx = CompileWgslCtx {
node_id: step.node_id,
params: &node.params,
port_defs: &node.ports,
inputs,
lut: lut.as_ref(),
consumed_outputs,
graph_textures: &graph_textures_cell,
};
let result =
evaluator
.compile_wgsl(&cctx)
.map_err(|reason| CompileError::NodeNotCompilable {
type_id: step.type_id.clone(),
reason,
})?;
if !result.decls.is_empty() {
decls.push_str(&result.decls);
if !result.decls.ends_with('\n') {
decls.push('\n');
}
}
let is_terminal = step.is_terminal;
if !result.body.is_empty() {
let target = if is_terminal {
&mut stroke_terminal_body
} else {
&mut shared_body
};
target.push_str(&result.body);
if !result.body.ends_with('\n') {
target.push('\n');
}
}
if is_terminal {
let (preview_node, preview_inputs, preview_consumed) =
preview_cctx_parts.expect("preview_cctx_parts built above for every terminal step");
let preview_cctx = CompileWgslCtx {
node_id: step.node_id,
params: &preview_node.params,
port_defs: &preview_node.ports,
inputs: preview_inputs,
lut: lut.as_ref(),
consumed_outputs: preview_consumed,
graph_textures: &graph_textures_cell,
};
let preview_result = evaluator
.compile_cursor_preview_body(&preview_cctx)
.map_err(|reason| CompileError::NodeNotCompilable {
type_id: step.type_id.clone(),
reason,
})?;
if !preview_result.body.is_empty() {
preview_terminal_body.push_str(&preview_result.body);
if !preview_result.body.ends_with('\n') {
preview_terminal_body.push('\n');
}
}
}
dab_fields.extend(result.dab_fields);
uniform_fields.extend(result.uniform_fields);
if !result.terminal_bindings.is_empty() {
if !terminal_bindings.is_empty() {
terminal_bindings.push('\n');
}
terminal_bindings.push_str(&result.terminal_bindings);
}
for (port_name, slot_idx) in &step.output_slots {
let pr = PortRef {
node: step.node_id,
port: port_name.clone(),
};
slot_to_port.insert(*slot_idx, pr.clone());
if let Some(expr) = result.outputs.get(port_name) {
output_exprs.insert(pr, expr.clone());
}
}
}
{
let (head, tail) = dab_fields.split_at_mut(INTRINSIC_DAB_HEADER_FIELDS);
let _ = head;
tail.sort_by_key(|f| std::cmp::Reverse(f.ty.align()));
}
uniform_fields.sort_by_key(|f| std::cmp::Reverse(f.ty.align()));
let dab_record_size = compute_struct_size(&dab_fields);
let uniform_size = compute_struct_size_for_uniforms(&uniform_fields);
let stroke_body = format!("{shared_body}{stroke_terminal_body}");
let preview_body = format!("{shared_body}{preview_terminal_body}");
let graph_texture_names = graph_textures_cell.into_inner();
if !terminal_bindings.is_empty() && !graph_texture_names.is_empty() {
return Err(CompileError::NodeNotCompilable {
type_id: "image".into(),
reason: format!(
"graph combines an `image` node with a terminal that owns @group(3) \
bindings ({} requested); this combination is not yet supported",
graph_texture_names.join(", ")
),
});
}
let stroke_wgsl = assemble_shader(
ShaderMode::Stroke,
&dab_fields,
&uniform_fields,
&decls,
&stroke_body,
&terminal_bindings,
&graph_texture_names,
);
let cursor_preview_wgsl = assemble_shader(
ShaderMode::CursorPreview,
&dab_fields,
&uniform_fields,
&decls,
&preview_body,
"",
&graph_texture_names,
);
let topology_hash = hash_graph_topology(graph);
let (brush_extent_factor, brush_extent_extra_px) =
compose_brush_extent(graph, plan, evaluators);
Ok(CompiledBrush {
stroke_wgsl,
cursor_preview_wgsl,
dab_layout: dab_fields,
dab_record_size,
uniform_layout: uniform_fields,
uniform_size,
topology_hash,
brush_extent_factor,
brush_extent_extra_px,
graph_texture_names,
})
}
pub fn pack_dab_record(
compiled: &CompiledBrush,
outputs: &HashMap<String, ScalarValue>,
bytes: &mut Vec<u8>,
) {
for field in compiled.dab_layout.iter().skip(INTRINSIC_DAB_HEADER_FIELDS) {
let before = bytes.len();
(field.pack)(outputs, bytes);
debug_assert_eq!(
bytes.len() - before,
field.ty.size(),
"DabField `{}` packer wrote {} bytes, expected {}",
field.name,
bytes.len() - before,
field.ty.size(),
);
}
}
pub fn pack_uniforms(
compiled: &CompiledBrush,
outputs: &HashMap<String, ScalarValue>,
bytes: &mut Vec<u8>,
) {
for field in &compiled.uniform_layout {
let before = bytes.len();
(field.pack)(outputs, bytes);
debug_assert_eq!(
bytes.len() - before,
field.ty.size(),
"UniformField `{}` packer wrote {} bytes, expected {}",
field.name,
bytes.len() - before,
field.ty.size(),
);
}
}
pub fn render_compiled_cursor_preview(
gpu: &mut crate::brush::gpu_context::BrushGpuContext,
radius: f32,
) -> Option<()> {
let compiled = gpu.dab_batch.compiled_brush.clone()?;
let bbox_canvas_px = radius * compiled.brush_extent_factor + compiled.brush_extent_extra_px;
let (target_view, target_w, target_h) = gpu.ensure_cursor_preview_mask(bbox_canvas_px)?;
if target_w == 0 || target_h == 0 || bbox_canvas_px < EPS_BBOX_CANVAS_PX {
return None;
}
let texture_half = (target_w.min(target_h) as f32) * 0.5;
let canvas_to_target = texture_half / bbox_canvas_px;
let bbox_target_px = texture_half;
let radius_target_px = (radius * canvas_to_target).max(EPS_RADIUS_TARGET_PX);
let cursor_preview_centre = [target_w as f32 * 0.5, target_h as f32 * 0.5];
let intrinsic = gpu.intrinsic_preview_header(target_w, target_h, cursor_preview_centre);
let total_uniform_size = INTRINSIC_UNIFORMS_SIZE + compiled.uniform_size;
let mut uniform_bytes: Vec<u8> = Vec::with_capacity(total_uniform_size);
pack_intrinsic_uniforms(&mut uniform_bytes, intrinsic);
let empty_outputs;
let outputs = match gpu.dab_batch.slot_outputs.as_ref() {
Some(o) => o,
None => {
empty_outputs = HashMap::new();
&empty_outputs
}
};
pack_uniforms(&compiled, outputs, &mut uniform_bytes);
if uniform_bytes.len() < total_uniform_size {
uniform_bytes.resize(total_uniform_size, 0);
}
let mut dab_bytes: Vec<u8> = Vec::with_capacity(compiled.dab_record_size);
pack_intrinsic_dab_header(
&mut dab_bytes,
cursor_preview_centre,
bbox_target_px,
radius_target_px,
);
pack_dab_record(&compiled, outputs, &mut dab_bytes);
if dab_bytes.len() < compiled.dab_record_size {
dab_bytes.resize(compiled.dab_record_size, 0);
}
gpu.pipelines.render_preview(
gpu.device,
gpu.queue,
&mut gpu.encoder,
&compiled,
&target_view,
(target_w, target_h),
&uniform_bytes,
&dab_bytes,
);
if let Some(preview) = gpu.preview.as_mut() {
preview.info = Some(crate::brush::eval::BrushCursorPreviewInfo {
half_extent_canvas_px: [bbox_canvas_px, bbox_canvas_px],
});
}
Some(())
}
fn apply_wire_remap(
expr: String,
source: &PortRef,
dest_node: NodeId,
dest_port: &str,
graph: &crate::nodegraph::Graph<BrushWireType>,
) -> String {
let src_range = graph
.nodes()
.get(&source.node)
.and_then(|n| {
n.ports
.iter()
.find(|p| p.name == source.port && p.dir == PortDir::Output)
})
.and_then(|p| p.natural_range);
let dst_range = graph
.nodes()
.get(&dest_node)
.and_then(|n| {
n.ports
.iter()
.find(|p| p.name == dest_port && p.dir == PortDir::Input)
})
.and_then(|p| p.natural_range);
let (Some((src_min, src_max)), Some((dst_min, dst_max))) = (src_range, dst_range) else {
return expr;
};
if (src_min - dst_min).abs() < 1e-6 && (src_max - dst_max).abs() < 1e-6 {
return expr;
}
let denom = src_max - src_min;
if denom.abs() < 1e-6 {
return format!("{:.6}", dst_min);
}
let scale = (dst_max - dst_min) / denom;
let bias = dst_min - src_min * scale;
format!("(({}) * {:.6} + {:.6})", expr, scale, bias)
}
fn hash_graph_topology(graph: &crate::nodegraph::Graph<BrushWireType>) -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
let mut node_ids: Vec<_> = graph.nodes().keys().copied().collect();
node_ids.sort_by_key(|n| n.0);
for id in &node_ids {
let node = &graph.nodes()[id];
id.0.hash(&mut hasher);
node.type_id.hash(&mut hasher);
if let Ok(s) = serde_json::to_string(&node.params) {
s.hash(&mut hasher);
}
for port in &node.ports {
port.name.hash(&mut hasher);
port.default.to_bits().hash(&mut hasher);
}
}
let mut conns: Vec<_> = graph.connections.iter().collect();
conns.sort_by_key(|c| {
(
c.from.node.0,
c.from.port.clone(),
c.to.node.0,
c.to.port.clone(),
)
});
for c in conns {
c.from.node.0.hash(&mut hasher);
c.from.port.hash(&mut hasher);
c.to.node.0.hash(&mut hasher);
c.to.port.hash(&mut hasher);
}
hasher.finish()
}
fn assemble_shader(
mode: ShaderMode,
dab_fields: &[DabField],
uniform_fields: &[UniformField],
node_decls: &str,
fs_body: &str,
terminal_bindings: &str,
graph_texture_names: &[String],
) -> String {
let mut out = String::new();
out.push_str(crate::gpu::canvas_lib::CANVAS_LIB);
out.push('\n');
out.push_str(include_str!("../../../shaders/brush/_shape.wgsl"));
out.push('\n');
out.push_str(include_str!("../../../shaders/brush/_noise.wgsl"));
out.push('\n');
out.push_str(include_str!("../../../shaders/brush/_prelude.wgsl"));
out.push('\n');
out.push_str("struct DabRecord {\n");
for f in dab_fields {
out.push_str(&format!(" {}: {},\n", f.name, f.ty.wgsl_name()));
}
out.push_str("};\n\n");
if uniform_fields.is_empty() {
out.push_str("struct Uniforms {\n");
out.push_str(" intrinsic: IntrinsicUniforms,\n");
out.push_str("};\n\n");
} else {
out.push_str("struct Uniforms {\n");
out.push_str(" intrinsic: IntrinsicUniforms,\n");
for f in uniform_fields {
out.push_str(&format!(" {}: {},\n", f.name, f.ty.wgsl_name()));
}
out.push_str("};\n\n");
}
out.push_str("@group(0) @binding(0) var<uniform> u: Uniforms;\n");
out.push_str("@group(1) @binding(0) var<storage, read> dabs: array<DabRecord>;\n");
if mode == ShaderMode::Stroke {
out.push_str("@group(2) @binding(0) var sel_tex: texture_2d<f32>;\n");
out.push_str("@group(2) @binding(1) var sel_smp: sampler;\n");
if !terminal_bindings.is_empty() {
out.push_str(terminal_bindings);
if !terminal_bindings.ends_with('\n') {
out.push('\n');
}
}
}
if !graph_texture_names.is_empty() {
out.push_str("@group(3) @binding(0) var graph_smp: sampler;\n");
for (i, _) in graph_texture_names.iter().enumerate() {
out.push_str(&format!(
"@group(3) @binding({}) var graph_tex_{}: texture_2d<f32>;\n",
1 + i,
i
));
}
}
out.push('\n');
out.push_str(node_decls);
out.push('\n');
match mode {
ShaderMode::Stroke => out.push_str(STROKE_VERTEX_STAGE_WGSL),
ShaderMode::CursorPreview => out.push_str(PREVIEW_VERTEX_STAGE_WGSL),
}
out.push('\n');
out.push_str("@fragment\n");
out.push_str("fn fs_main(in: VsOut) -> @location(0) vec4<f32> {\n");
out.push_str(" let d = dabs[in.dab_idx];\n");
out.push_str(" let target_pos = in.target_pos;\n");
out.push_str(" let local = target_pos - d.pos;\n");
out.push_str(" let local_dist_px = length(local);\n");
out.push_str(" if (local_dist_px >= d.bbox_target_px) {\n");
out.push_str(" discard;\n");
out.push_str(" }\n");
out.push_str(" let local_uv = local * d.inv_radius_target_px;\n");
out.push_str(" let local_dist = length(local_uv);\n");
out.push_str(" let theta = atan2(local_uv.y, local_uv.x) - u.intrinsic.view_rotation;\n");
out.push_str(" let canvas_size = vec2<f32>(\n");
out.push_str(" f32(u.intrinsic.canvas_size.x),\n");
out.push_str(" f32(u.intrinsic.canvas_size.y),\n");
out.push_str(" );\n");
out.push_str(" let canvas_origin = vec2<f32>(\n");
out.push_str(" f32(u.intrinsic.canvas_origin.x),\n");
out.push_str(" f32(u.intrinsic.canvas_origin.y),\n");
out.push_str(" );\n");
match mode {
ShaderMode::Stroke => out.push_str(
" let sel = textureSampleLevel(sel_tex, sel_smp, plane_to_selection_uv(target_pos, canvas_origin, canvas_size), 0.0).r;\n",
),
ShaderMode::CursorPreview => out.push_str(" let sel: f32 = 1.0;\n"),
}
out.push_str(fs_body);
out.push_str("}\n");
out
}
const STROKE_VERTEX_STAGE_WGSL: &str = r#"
struct VsOut {
@builtin(position) clip: vec4<f32>,
@location(0) target_pos: vec2<f32>,
@location(1) @interpolate(flat) dab_idx: u32,
};
fn quad_corner(vi: u32) -> vec2<f32> {
var corners = array<vec2<f32>, 6>(
vec2<f32>(0.0, 0.0),
vec2<f32>(1.0, 0.0),
vec2<f32>(0.0, 1.0),
vec2<f32>(1.0, 0.0),
vec2<f32>(1.0, 1.0),
vec2<f32>(0.0, 1.0),
);
return corners[vi];
}
@vertex
fn vs_main(
@builtin(vertex_index) vi: u32,
@builtin(instance_index) ii: u32,
) -> VsOut {
let dab = dabs[ii];
let corner = quad_corner(vi);
// `dab.bbox_target_px` is the dab's bbox half-extent in the target's
// pixel space (stroke target ≡ canvas px). The fragment stage
// discards past the same bound, so the quad covers exactly what
// the shader can write — no waste, no clipping. The CPU side packs
// the same value into the dab record and uses it for the
// layer-clip bbox, so the save-point system tracks the same
// footprint the shader writes.
let quad_half = dab.bbox_target_px;
let target_pos = dab.pos + (corner * 2.0 - vec2<f32>(1.0, 1.0)) * quad_half;
let layer_offset = u.intrinsic.layer_offset;
let layer_size = u.intrinsic.layer_size;
let local = target_pos - vec2<f32>(f32(layer_offset.x), f32(layer_offset.y));
let layer_w = f32(layer_size.x);
let layer_h = f32(layer_size.y);
let clip = vec2<f32>(
local.x / layer_w * 2.0 - 1.0,
1.0 - local.y / layer_h * 2.0,
);
var out: VsOut;
out.clip = vec4<f32>(clip, 0.0, 1.0);
out.target_pos = target_pos;
out.dab_idx = ii;
return out;
}
"#;
const PREVIEW_VERTEX_STAGE_WGSL: &str = r#"
struct VsOut {
@builtin(position) clip: vec4<f32>,
@location(0) target_pos: vec2<f32>,
@location(1) @interpolate(flat) dab_idx: u32,
};
fn quad_corner(vi: u32) -> vec2<f32> {
var corners = array<vec2<f32>, 6>(
vec2<f32>(0.0, 0.0),
vec2<f32>(1.0, 0.0),
vec2<f32>(0.0, 1.0),
vec2<f32>(1.0, 0.0),
vec2<f32>(1.0, 1.0),
vec2<f32>(0.0, 1.0),
);
return corners[vi];
}
@vertex
fn vs_main(@builtin(vertex_index) vi: u32) -> VsOut {
let dab = dabs[0];
let corner = quad_corner(vi);
// Read `dab.pos` instead of `u.intrinsic.cursor_preview_centre` so the
// dab record is the single source of truth for positioning. The
// CPU side packs `pos = cursor_preview_centre`, making the two equivalent
// by construction, but threading through `dab.pos` keeps the
// vertex structurally identical to stroke's modulo the clip-space
// mapping — the invariant is the same: target-space pos, bbox in
// target px.
let target_pos = dab.pos + (corner * 2.0 - vec2<f32>(1.0, 1.0)) * dab.bbox_target_px;
let cursor_preview_size_f = vec2<f32>(
f32(u.intrinsic.cursor_preview_size.x),
f32(u.intrinsic.cursor_preview_size.y),
);
let clip = vec2<f32>(
target_pos.x / cursor_preview_size_f.x * 2.0 - 1.0,
1.0 - target_pos.y / cursor_preview_size_f.y * 2.0,
);
var out: VsOut;
out.clip = vec4<f32>(clip, 0.0, 1.0);
out.target_pos = target_pos;
out.dab_idx = 0u;
return out;
}
"#;