spine2d_wgpu/
renderer.rs

1use spine2d::{BlendMode, DrawList};
2use wgpu::util::DeviceExt;
3
4#[repr(C)]
5#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
6struct GpuVertex {
7    position: [f32; 2],
8    uv: [f32; 2],
9    color: [f32; 4],
10    dark_color: [f32; 4],
11}
12
13#[repr(C)]
14#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
15struct Globals {
16    clip_from_world: [[f32; 4]; 4],
17}
18
19pub struct SpineRenderer {
20    pipelines: Pipelines,
21    pipelines_pma: Pipelines,
22    globals_buffer: wgpu::Buffer,
23    globals_bind_group: wgpu::BindGroup,
24    texture_bind_group_layout: wgpu::BindGroupLayout,
25    vertex_buffer: wgpu::Buffer,
26    index_buffer: wgpu::Buffer,
27    vertex_capacity: usize,
28    index_capacity: usize,
29}
30
31struct Pipelines {
32    normal: wgpu::RenderPipeline,
33    additive: wgpu::RenderPipeline,
34    multiply: wgpu::RenderPipeline,
35    screen: wgpu::RenderPipeline,
36}
37
38impl Pipelines {
39    fn by_blend(&self, blend: BlendMode) -> &wgpu::RenderPipeline {
40        match blend {
41            BlendMode::Normal => &self.normal,
42            BlendMode::Additive => &self.additive,
43            BlendMode::Multiply => &self.multiply,
44            BlendMode::Screen => &self.screen,
45        }
46    }
47}
48
49impl SpineRenderer {
50    pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
51        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
52            label: Some("spine2d-wgpu shader"),
53            source: wgpu::ShaderSource::Wgsl(SHADER.into()),
54        });
55
56        let globals_bind_group_layout =
57            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
58                label: Some("globals bind group layout"),
59                entries: &[wgpu::BindGroupLayoutEntry {
60                    binding: 0,
61                    visibility: wgpu::ShaderStages::VERTEX,
62                    ty: wgpu::BindingType::Buffer {
63                        ty: wgpu::BufferBindingType::Uniform,
64                        has_dynamic_offset: false,
65                        min_binding_size: None,
66                    },
67                    count: None,
68                }],
69            });
70
71        let texture_bind_group_layout =
72            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
73                label: Some("texture bind group layout"),
74                entries: &[
75                    wgpu::BindGroupLayoutEntry {
76                        binding: 0,
77                        visibility: wgpu::ShaderStages::FRAGMENT,
78                        ty: wgpu::BindingType::Texture {
79                            multisampled: false,
80                            view_dimension: wgpu::TextureViewDimension::D2,
81                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
82                        },
83                        count: None,
84                    },
85                    wgpu::BindGroupLayoutEntry {
86                        binding: 1,
87                        visibility: wgpu::ShaderStages::FRAGMENT,
88                        ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
89                        count: None,
90                    },
91                ],
92            });
93
94        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
95            label: Some("spine2d-wgpu pipeline layout"),
96            bind_group_layouts: &[&globals_bind_group_layout, &texture_bind_group_layout],
97            push_constant_ranges: &[],
98        });
99
100        let pipelines = create_pipelines(device, &pipeline_layout, &shader, color_format, false);
101        let pipelines_pma = create_pipelines(device, &pipeline_layout, &shader, color_format, true);
102
103        let globals = Globals {
104            clip_from_world: [[0.0; 4]; 4],
105        };
106        let globals_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
107            label: Some("globals buffer"),
108            contents: bytemuck::bytes_of(&globals),
109            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
110        });
111        let globals_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
112            label: Some("globals bind group"),
113            layout: &globals_bind_group_layout,
114            entries: &[wgpu::BindGroupEntry {
115                binding: 0,
116                resource: globals_buffer.as_entire_binding(),
117            }],
118        });
119
120        let vertex_capacity = 1024;
121        let index_capacity = 2048;
122        let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
123            label: Some("spine2d vertices"),
124            size: (vertex_capacity * std::mem::size_of::<GpuVertex>()) as u64,
125            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
126            mapped_at_creation: false,
127        });
128        let index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
129            label: Some("spine2d indices"),
130            size: (index_capacity * std::mem::size_of::<u32>()) as u64,
131            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
132            mapped_at_creation: false,
133        });
134
135        Self {
136            pipelines,
137            pipelines_pma,
138            globals_buffer,
139            globals_bind_group,
140            texture_bind_group_layout,
141            vertex_buffer,
142            index_buffer,
143            vertex_capacity,
144            index_capacity,
145        }
146    }
147
148    pub fn texture_bind_group_layout(&self) -> &wgpu::BindGroupLayout {
149        &self.texture_bind_group_layout
150    }
151
152    pub fn update_globals_ortho_centered(&self, queue: &wgpu::Queue, width: f32, height: f32) {
153        // Treat world coordinates as centered pixels: x in [-w/2,w/2], y in [-h/2,h/2].
154        let sx = 2.0 / width.max(1.0);
155        let sy = 2.0 / height.max(1.0);
156        let globals = Globals {
157            clip_from_world: [
158                [sx, 0.0, 0.0, 0.0],
159                [0.0, sy, 0.0, 0.0],
160                [0.0, 0.0, 1.0, 0.0],
161                [0.0, 0.0, 0.0, 1.0],
162            ],
163        };
164        queue.write_buffer(&self.globals_buffer, 0, bytemuck::bytes_of(&globals));
165    }
166
167    pub fn update_globals_matrix(&self, queue: &wgpu::Queue, clip_from_world: [[f32; 4]; 4]) {
168        let globals = Globals { clip_from_world };
169        queue.write_buffer(&self.globals_buffer, 0, bytemuck::bytes_of(&globals));
170    }
171
172    pub fn upload(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, draw_list: &DrawList) {
173        let vertices = draw_list
174            .vertices
175            .iter()
176            .map(|v| GpuVertex {
177                position: v.position,
178                uv: v.uv,
179                color: v.color,
180                dark_color: v.dark_color,
181            })
182            .collect::<Vec<_>>();
183
184        self.ensure_buffers(device, vertices.len(), draw_list.indices.len());
185        queue.write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(&vertices));
186        queue.write_buffer(
187            &self.index_buffer,
188            0,
189            bytemuck::cast_slice(&draw_list.indices),
190        );
191    }
192
193    pub fn render<'a>(
194        &'a self,
195        pass: &mut wgpu::RenderPass<'a>,
196        draw_list: &'a DrawList,
197        textures: &'a dyn TextureProvider,
198    ) {
199        if draw_list.indices.is_empty() || draw_list.vertices.is_empty() {
200            return;
201        }
202
203        pass.set_bind_group(0, &self.globals_bind_group, &[]);
204        pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
205        pass.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
206
207        for draw in &draw_list.draws {
208            let pipeline = if draw.premultiplied_alpha {
209                self.pipelines_pma.by_blend(draw.blend)
210            } else {
211                self.pipelines.by_blend(draw.blend)
212            };
213            pass.set_pipeline(pipeline);
214            if let Some(bind_group) = textures.bind_group_for(&draw.texture_path) {
215                pass.set_bind_group(1, bind_group, &[]);
216            }
217            let start = draw.first_index as u32;
218            let end = (draw.first_index + draw.index_count) as u32;
219            pass.draw_indexed(start..end, 0, 0..1);
220        }
221    }
222
223    fn ensure_buffers(&mut self, device: &wgpu::Device, vertices: usize, indices: usize) {
224        if vertices > self.vertex_capacity {
225            while self.vertex_capacity < vertices {
226                self.vertex_capacity *= 2;
227            }
228            self.vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
229                label: Some("spine2d vertices"),
230                size: (self.vertex_capacity * std::mem::size_of::<GpuVertex>()) as u64,
231                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
232                mapped_at_creation: false,
233            });
234        }
235        if indices > self.index_capacity {
236            while self.index_capacity < indices {
237                self.index_capacity *= 2;
238            }
239            self.index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
240                label: Some("spine2d indices"),
241                size: (self.index_capacity * std::mem::size_of::<u32>()) as u64,
242                usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
243                mapped_at_creation: false,
244            });
245        }
246    }
247}
248
249fn create_pipelines(
250    device: &wgpu::Device,
251    layout: &wgpu::PipelineLayout,
252    shader: &wgpu::ShaderModule,
253    color_format: wgpu::TextureFormat,
254    premultiplied_alpha: bool,
255) -> Pipelines {
256    Pipelines {
257        normal: create_pipeline(
258            device,
259            layout,
260            shader,
261            color_format,
262            BlendMode::Normal,
263            premultiplied_alpha,
264        ),
265        additive: create_pipeline(
266            device,
267            layout,
268            shader,
269            color_format,
270            BlendMode::Additive,
271            premultiplied_alpha,
272        ),
273        multiply: create_pipeline(
274            device,
275            layout,
276            shader,
277            color_format,
278            BlendMode::Multiply,
279            premultiplied_alpha,
280        ),
281        screen: create_pipeline(
282            device,
283            layout,
284            shader,
285            color_format,
286            BlendMode::Screen,
287            premultiplied_alpha,
288        ),
289    }
290}
291
292fn create_pipeline(
293    device: &wgpu::Device,
294    layout: &wgpu::PipelineLayout,
295    shader: &wgpu::ShaderModule,
296    color_format: wgpu::TextureFormat,
297    blend: BlendMode,
298    premultiplied_alpha: bool,
299) -> wgpu::RenderPipeline {
300    let label = match (blend, premultiplied_alpha) {
301        (BlendMode::Normal, false) => "spine2d-wgpu pipeline normal",
302        (BlendMode::Additive, false) => "spine2d-wgpu pipeline additive",
303        (BlendMode::Multiply, false) => "spine2d-wgpu pipeline multiply",
304        (BlendMode::Screen, false) => "spine2d-wgpu pipeline screen",
305        (BlendMode::Normal, true) => "spine2d-wgpu pipeline normal pma",
306        (BlendMode::Additive, true) => "spine2d-wgpu pipeline additive pma",
307        (BlendMode::Multiply, true) => "spine2d-wgpu pipeline multiply pma",
308        (BlendMode::Screen, true) => "spine2d-wgpu pipeline screen pma",
309    };
310
311    device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
312        label: Some(label),
313        layout: Some(layout),
314        vertex: wgpu::VertexState {
315            module: shader,
316            entry_point: Some("vs_main"),
317            compilation_options: Default::default(),
318            buffers: &[wgpu::VertexBufferLayout {
319                array_stride: std::mem::size_of::<GpuVertex>() as u64,
320                step_mode: wgpu::VertexStepMode::Vertex,
321                attributes: &wgpu::vertex_attr_array![
322                    0 => Float32x2,
323                    1 => Float32x2,
324                    2 => Float32x4,
325                    3 => Float32x4
326                ],
327            }],
328        },
329        fragment: Some(wgpu::FragmentState {
330            module: shader,
331            entry_point: Some("fs_main"),
332            compilation_options: Default::default(),
333            targets: &[Some(wgpu::ColorTargetState {
334                format: color_format,
335                blend: Some(blend_state(blend, premultiplied_alpha)),
336                write_mask: wgpu::ColorWrites::ALL,
337            })],
338        }),
339        primitive: wgpu::PrimitiveState {
340            topology: wgpu::PrimitiveTopology::TriangleList,
341            strip_index_format: None,
342            front_face: wgpu::FrontFace::Ccw,
343            cull_mode: None,
344            ..Default::default()
345        },
346        depth_stencil: None,
347        multisample: wgpu::MultisampleState::default(),
348        multiview: None,
349        cache: None,
350    })
351}
352
353fn blend_state(blend: BlendMode, premultiplied_alpha: bool) -> wgpu::BlendState {
354    use wgpu::{BlendComponent, BlendFactor, BlendOperation};
355
356    // Mirrors upstream `spine-ts/spine-webgl`:
357    // glBlendFuncSeparate(srcColorBlend, dstBlend, srcAlphaBlend, dstBlend)
358    // where `srcAlphaBlend` is always ONE.
359    let (src_color, dst) = match blend {
360        BlendMode::Normal => (
361            src_color_for_alpha(premultiplied_alpha),
362            BlendFactor::OneMinusSrcAlpha,
363        ),
364        BlendMode::Additive => (src_color_for_alpha(premultiplied_alpha), BlendFactor::One),
365        BlendMode::Multiply => (BlendFactor::Dst, BlendFactor::OneMinusSrcAlpha),
366        BlendMode::Screen => (BlendFactor::One, BlendFactor::OneMinusSrc),
367    };
368
369    wgpu::BlendState {
370        color: BlendComponent {
371            src_factor: src_color,
372            dst_factor: dst,
373            operation: BlendOperation::Add,
374        },
375        alpha: BlendComponent {
376            src_factor: BlendFactor::One,
377            dst_factor: dst,
378            operation: BlendOperation::Add,
379        },
380    }
381}
382
383fn src_color_for_alpha(premultiplied_alpha: bool) -> wgpu::BlendFactor {
384    if premultiplied_alpha {
385        wgpu::BlendFactor::One
386    } else {
387        wgpu::BlendFactor::SrcAlpha
388    }
389}
390
391pub trait TextureProvider {
392    fn bind_group_for(&self, texture_path: &str) -> Option<&wgpu::BindGroup>;
393}
394
395pub struct HashMapTextureProvider {
396    pub bind_groups: std::collections::HashMap<String, wgpu::BindGroup>,
397}
398
399impl TextureProvider for HashMapTextureProvider {
400    fn bind_group_for(&self, texture_path: &str) -> Option<&wgpu::BindGroup> {
401        self.bind_groups.get(texture_path)
402    }
403}
404
405pub fn create_texture_bind_group(
406    device: &wgpu::Device,
407    layout: &wgpu::BindGroupLayout,
408    view: &wgpu::TextureView,
409    sampler: &wgpu::Sampler,
410) -> wgpu::BindGroup {
411    device.create_bind_group(&wgpu::BindGroupDescriptor {
412        label: Some("spine2d texture bind group"),
413        layout,
414        entries: &[
415            wgpu::BindGroupEntry {
416                binding: 0,
417                resource: wgpu::BindingResource::TextureView(view),
418            },
419            wgpu::BindGroupEntry {
420                binding: 1,
421                resource: wgpu::BindingResource::Sampler(sampler),
422            },
423        ],
424    })
425}
426
427pub fn create_sampler_for_atlas_page(
428    device: &wgpu::Device,
429    page: &spine2d::AtlasPage,
430) -> wgpu::Sampler {
431    let (min_filter, mipmap_filter) = to_wgpu_min_mipmap_filter(&page.min_filter);
432    let mag_filter = to_wgpu_mag_filter(&page.mag_filter);
433    let address_mode_u = to_wgpu_address_mode(page.wrap_u);
434    let address_mode_v = to_wgpu_address_mode(page.wrap_v);
435
436    device.create_sampler(&wgpu::SamplerDescriptor {
437        label: Some("spine2d atlas sampler"),
438        mag_filter,
439        min_filter,
440        mipmap_filter,
441        address_mode_u,
442        address_mode_v,
443        ..Default::default()
444    })
445}
446
447fn to_wgpu_address_mode(wrap: spine2d::AtlasWrap) -> wgpu::AddressMode {
448    match wrap {
449        spine2d::AtlasWrap::ClampToEdge => wgpu::AddressMode::ClampToEdge,
450        spine2d::AtlasWrap::Repeat => wgpu::AddressMode::Repeat,
451    }
452}
453
454fn to_wgpu_mag_filter(filter: &spine2d::AtlasFilter) -> wgpu::FilterMode {
455    match filter {
456        spine2d::AtlasFilter::Nearest
457        | spine2d::AtlasFilter::MipMapNearestNearest
458        | spine2d::AtlasFilter::MipMapLinearNearest => wgpu::FilterMode::Nearest,
459        spine2d::AtlasFilter::Linear
460        | spine2d::AtlasFilter::MipMap
461        | spine2d::AtlasFilter::MipMapNearestLinear
462        | spine2d::AtlasFilter::MipMapLinearLinear
463        | spine2d::AtlasFilter::Other(_) => wgpu::FilterMode::Linear,
464    }
465}
466
467fn to_wgpu_min_mipmap_filter(
468    filter: &spine2d::AtlasFilter,
469) -> (wgpu::FilterMode, wgpu::FilterMode) {
470    match filter {
471        spine2d::AtlasFilter::Nearest => (wgpu::FilterMode::Nearest, wgpu::FilterMode::Nearest),
472        spine2d::AtlasFilter::Linear => (wgpu::FilterMode::Linear, wgpu::FilterMode::Nearest),
473        spine2d::AtlasFilter::MipMap | spine2d::AtlasFilter::MipMapLinearLinear => {
474            (wgpu::FilterMode::Linear, wgpu::FilterMode::Linear)
475        }
476        spine2d::AtlasFilter::MipMapNearestNearest => {
477            (wgpu::FilterMode::Nearest, wgpu::FilterMode::Nearest)
478        }
479        spine2d::AtlasFilter::MipMapNearestLinear => {
480            (wgpu::FilterMode::Nearest, wgpu::FilterMode::Linear)
481        }
482        spine2d::AtlasFilter::MipMapLinearNearest => {
483            (wgpu::FilterMode::Linear, wgpu::FilterMode::Nearest)
484        }
485        spine2d::AtlasFilter::Other(_) => (wgpu::FilterMode::Linear, wgpu::FilterMode::Nearest),
486    }
487}
488
489const SHADER: &str = r#"
490struct Globals {
491  clip_from_world: mat4x4<f32>,
492};
493
494@group(0) @binding(0)
495var<uniform> globals: Globals;
496
497struct VsIn {
498  @location(0) position: vec2<f32>,
499  @location(1) uv: vec2<f32>,
500  @location(2) light_color: vec4<f32>,
501  @location(3) dark_color: vec4<f32>,
502};
503
504struct VsOut {
505  @builtin(position) position: vec4<f32>,
506  @location(0) uv: vec2<f32>,
507  @location(1) light_color: vec4<f32>,
508  @location(2) dark_color: vec4<f32>,
509};
510
511@vertex
512fn vs_main(in: VsIn) -> VsOut {
513  var out: VsOut;
514  out.position = globals.clip_from_world * vec4<f32>(in.position, 0.0, 1.0);
515  out.uv = in.uv;
516  out.light_color = in.light_color;
517  out.dark_color = in.dark_color;
518  return out;
519}
520
521@group(1) @binding(0)
522var tex: texture_2d<f32>;
523
524@group(1) @binding(1)
525var samp: sampler;
526
527@fragment
528fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
529  let tex_color = textureSample(tex, samp, in.uv);
530  let alpha = tex_color.a * in.light_color.a;
531  let rgb = ((tex_color.a - 1.0) * in.dark_color.a + 1.0 - tex_color.rgb) * in.dark_color.rgb
532    + tex_color.rgb * in.light_color.rgb;
533  return vec4<f32>(rgb, alpha);
534}
535"#;