Skip to main content

viewport_lib_wind/
plugin.rs

1//! Runtime + GPU plugin that registers the wind deformer and pushes the
2//! current wind state into its slot params every frame.
3
4use std::sync::{Arc, Mutex};
5
6use viewport_lib::MeshId;
7use viewport_lib::resources::DeviceResources;
8use viewport_lib::runtime::{
9    GpuFrameContext, GpuPlugin, RuntimePlugin, RuntimeStepContext, gpu_phase, phase,
10};
11use viewport_lib::{DeformSlotHandle, DeformStage, DeformerDesc, DeformerId};
12
13use crate::field::{WindAuthoring, WindField};
14use crate::shader::{
15    WIND_DEFORMER_BODY, WIND_DEFORMER_NAME, WIND_MATERIAL_PARAMS_STRIDE_BYTES,
16    WIND_SWAY_STRIDE_BYTES,
17};
18
19/// Per-mesh material parameters for the wind deformer.
20///
21/// Attach via [`WindPlugin::attach_material_params`]. Each multiplier
22/// applies on top of the corresponding global field knob: `strength`
23/// scales the total displacement, `density` scales the spatial wavelength
24/// of the gust noise, `speed` scales the time-rate of the gust phase.
25/// Meshes without attached params see `strength = density = speed = 1.0`
26/// in the shader.
27#[derive(Clone, Copy, Debug)]
28pub struct WindMaterialParams {
29    /// Multiplier on the field magnitude.
30    pub strength: f32,
31    /// Spatial wavelength multiplier on top of the field's `spatial_density`.
32    pub density: f32,
33    /// Time-rate multiplier on top of the field's `gust_frequency`.
34    pub speed: f32,
35}
36
37impl Default for WindMaterialParams {
38    fn default() -> Self {
39        Self {
40            strength: 1.0,
41            density: 1.0,
42            speed: 1.0,
43        }
44    }
45}
46
47impl WindMaterialParams {
48    /// Pack into the 12-byte layout the deformer body expects.
49    pub fn to_bytes(self) -> [u8; 12] {
50        let words: [f32; 3] = [self.strength, self.density, self.speed];
51        let mut out = [0u8; 12];
52        out.copy_from_slice(bytemuck::cast_slice(&words));
53        out
54    }
55}
56
57/// Per-vertex sway-mask weights.
58///
59/// One `f32` per vertex, in the same order as the mesh's position
60/// attribute. `0.0` is "this vertex never moves", `1.0` is "this vertex
61/// moves at full strength". The `uniform` and `height_falloff` helpers
62/// cover the common cases.
63#[derive(Clone, Debug)]
64pub struct WindSwayWeights {
65    /// One sway mask per vertex.
66    pub sway_mask: Vec<f32>,
67}
68
69impl WindSwayWeights {
70    /// All vertices get the same mask value. Use `1.0` for a uniformly
71    /// swaying flag or banner.
72    pub fn uniform(vertex_count: usize, mask: f32) -> Self {
73        Self {
74            sway_mask: vec![mask; vertex_count],
75        }
76    }
77
78    /// Sway scales with vertex height: vertices at `base_y` get `0.0`,
79    /// vertices at `top_y` get `1.0`, anything outside the range is
80    /// clamped. Use this for grass blades and tree foliage, where the
81    /// root stays put and the tip catches the wind.
82    pub fn height_falloff(positions: &[[f32; 3]], base_y: f32, top_y: f32) -> Self {
83        let range = top_y - base_y;
84        let sway_mask = if range.abs() < f32::EPSILON {
85            vec![0.0; positions.len()]
86        } else {
87            positions
88                .iter()
89                .map(|p| ((p[1] - base_y) / range).clamp(0.0, 1.0))
90                .collect()
91        };
92        Self { sway_mask }
93    }
94}
95
96/// GPU-side state owned by the plugin. Held behind an `Arc<Mutex<_>>` so
97/// the `RuntimePlugin` and `GpuPlugin` halves can share access without
98/// taking ownership of each other.
99struct WindInner {
100    field: WindField,
101    /// `Some` once [`WindPlugin::install`] has registered the deformer.
102    slot: Option<DeformSlotHandle>,
103}
104
105/// Wind plugin handle.
106///
107/// Typical setup:
108///
109/// ```ignore
110/// let wind = WindPlugin::new(WindAuthoring::default());
111/// wind.install(&mut resources, &device)?;
112///
113/// let runtime = ViewportRuntime::new()
114///     .with_plugin(wind.cpu_plugin())
115///     .with_gpu_plugin(wind.gpu_plugin());
116///
117/// // For each grass / banner / branch mesh:
118/// let mask = WindSwayWeights::height_falloff(&positions, 0.0, top_y);
119/// wind.attach_sway_mask(&mut resources, &device, mesh_id, &mask)?;
120/// ```
121///
122/// The CPU half advances the wind clock during `runtime.step()`. The GPU
123/// half writes the current field state into the deformer's slot params
124/// during `runtime.pre_prepare()` so every wind-affected mesh sees the
125/// same global field that frame.
126pub struct WindPlugin {
127    inner: Arc<Mutex<WindInner>>,
128}
129
130impl WindPlugin {
131    /// Create a new plugin with the given authoring values. Call
132    /// [`Self::install`] before either trait-object half runs.
133    pub fn new(authoring: WindAuthoring) -> Self {
134        Self {
135            inner: Arc::new(Mutex::new(WindInner {
136                field: WindField::new(authoring),
137                slot: None,
138            })),
139        }
140    }
141
142    /// Register the wind deformer against viewport-lib's mesh shader family.
143    ///
144    /// Composes the deformer body into every mesh-family shader, allocates
145    /// a registry slot, and stashes a handle the GPU half uses each frame
146    /// to push the wind state into the slot's `slot_params`.
147    ///
148    /// Returns the assigned [`DeformerId`] in case the host wants to
149    /// reference the slot directly.
150    pub fn install(
151        &self,
152        resources: &mut DeviceResources,
153        device: &wgpu::Device,
154    ) -> Result<DeformerId, viewport_lib::ViewportError> {
155        let desc = DeformerDesc {
156            name: WIND_DEFORMER_NAME,
157            stage: DeformStage::WorldSpace,
158            priority: 0,
159            wgsl_body: WIND_DEFORMER_BODY.to_string(),
160            per_vertex_stride: WIND_SWAY_STRIDE_BYTES,
161        };
162        let id = resources.register_deformer(device, desc)?;
163        let handle = resources.deform_slot_handle(id);
164        self.inner.lock().expect("wind plugin poisoned").slot = Some(handle);
165        Ok(id)
166    }
167
168    /// Attach a sway mask to one mesh. The mask's length must equal the
169    /// mesh's vertex count.
170    ///
171    /// Returns `false` and does nothing if [`Self::install`] has not run
172    /// yet.
173    pub fn attach_sway_mask(
174        &self,
175        resources: &mut DeviceResources,
176        device: &wgpu::Device,
177        mesh_id: MeshId,
178        weights: &WindSwayWeights,
179    ) -> bool {
180        let inner = self.inner.lock().expect("wind plugin poisoned");
181        let Some(handle) = inner.slot.as_ref() else {
182            return false;
183        };
184        let slot = handle.slot();
185        drop(inner);
186        resources.attach_deform_slot(
187            device,
188            mesh_id,
189            slot,
190            WIND_SWAY_STRIDE_BYTES,
191            bytemuck::cast_slice(&weights.sway_mask),
192        );
193        true
194    }
195
196    /// Detach the sway mask from one mesh.
197    pub fn detach_sway_mask(
198        &self,
199        resources: &mut DeviceResources,
200        device: &wgpu::Device,
201        mesh_id: MeshId,
202    ) -> bool {
203        let slot = match self
204            .inner
205            .lock()
206            .expect("wind plugin poisoned")
207            .slot
208            .as_ref()
209        {
210            Some(h) => h.slot(),
211            None => return false,
212        };
213        resources.detach_deform_slot(device, mesh_id, slot)
214    }
215
216    /// Attach per-mesh material multipliers (strength / density / speed).
217    ///
218    /// Optional. Meshes that only have a sway mask attached use defaults of
219    /// 1.0 across the board, matching the shipped global formula. Hosts
220    /// that import per-material wind params from their content pipeline
221    /// (e.g. Unity `_WindStrength`, `_WindDensity`, `_WindSpeed`) call this
222    /// once after [`Self::attach_sway_mask`] for each mesh.
223    ///
224    /// Per-(mesh, instance) under the hood; for hosts that don't care about
225    /// multiple instances per mesh, `instance_id = 0` is the convention
226    /// and is what the shader body reads.
227    ///
228    /// Returns `false` and does nothing if [`Self::install`] has not run.
229    pub fn attach_material_params(
230        &self,
231        resources: &mut DeviceResources,
232        device: &wgpu::Device,
233        queue: &wgpu::Queue,
234        mesh_id: MeshId,
235        params: &WindMaterialParams,
236    ) -> bool {
237        let inner = self.inner.lock().expect("wind plugin poisoned");
238        let Some(handle) = inner.slot.as_ref() else {
239            return false;
240        };
241        let slot = handle.slot();
242        drop(inner);
243        let bytes = params.to_bytes();
244        resources.attach_deform_slot_instance(
245            device,
246            queue,
247            mesh_id,
248            0,
249            slot,
250            WIND_MATERIAL_PARAMS_STRIDE_BYTES,
251            &bytes,
252        );
253        true
254    }
255
256    /// Trait object for the CPU half. Runs at [`phase::PREPARE`].
257    pub fn cpu_plugin(&self) -> impl RuntimePlugin {
258        WindCpuHalf {
259            inner: self.inner.clone(),
260        }
261    }
262
263    /// Trait object for the GPU half. Runs at [`gpu_phase::PRE_PREPARE`].
264    pub fn gpu_plugin(&self) -> impl GpuPlugin {
265        WindGpuHalf {
266            inner: self.inner.clone(),
267        }
268    }
269
270    /// Replace the authoring values. The wind clock is preserved.
271    pub fn set_authoring(&self, authoring: WindAuthoring) {
272        self.inner
273            .lock()
274            .expect("wind plugin poisoned")
275            .field
276            .set_authoring(authoring);
277    }
278
279    /// Snapshot the field for CPU consumers. Returns a clone so the caller
280    /// can sample without holding the lock for the read.
281    pub fn field(&self) -> WindField {
282        self.inner
283            .lock()
284            .expect("wind plugin poisoned")
285            .field
286            .clone()
287    }
288}
289
290struct WindCpuHalf {
291    inner: Arc<Mutex<WindInner>>,
292}
293
294impl RuntimePlugin for WindCpuHalf {
295    fn priority(&self) -> i32 {
296        phase::PREPARE
297    }
298
299    fn step(&mut self, ctx: &mut RuntimeStepContext<'_>) {
300        self.inner
301            .lock()
302            .expect("wind plugin poisoned")
303            .field
304            .advance(ctx.dt);
305    }
306}
307
308struct WindGpuHalf {
309    inner: Arc<Mutex<WindInner>>,
310}
311
312impl GpuPlugin for WindGpuHalf {
313    fn priority(&self) -> i32 {
314        gpu_phase::PRE_PREPARE
315    }
316
317    fn pre_prepare(
318        &mut self,
319        _device: &wgpu::Device,
320        queue: &wgpu::Queue,
321        _ctx: &GpuFrameContext<'_>,
322    ) -> Vec<wgpu::CommandBuffer> {
323        let inner = self.inner.lock().expect("wind plugin poisoned");
324        if let Some(handle) = inner.slot.as_ref() {
325            let params = inner.field.to_slot_params();
326            handle.write(queue, &params);
327        }
328        Vec::new()
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335    use glam::Vec3;
336
337    #[test]
338    fn uniform_weights_have_right_length_and_value() {
339        let w = WindSwayWeights::uniform(5, 0.7);
340        assert_eq!(w.sway_mask.len(), 5);
341        assert!(w.sway_mask.iter().all(|&m| (m - 0.7).abs() < 1e-6));
342    }
343
344    #[test]
345    fn height_falloff_is_zero_at_base_and_one_at_top() {
346        let positions = vec![
347            [0.0, 0.0, 0.0],
348            [0.0, 1.0, 0.0],
349            [0.0, 2.0, 0.0],
350            [0.0, -1.0, 0.0],
351            [0.0, 5.0, 0.0],
352        ];
353        let w = WindSwayWeights::height_falloff(&positions, 0.0, 2.0);
354        assert_eq!(w.sway_mask[0], 0.0);
355        assert_eq!(w.sway_mask[1], 0.5);
356        assert_eq!(w.sway_mask[2], 1.0);
357        assert_eq!(w.sway_mask[3], 0.0);
358        assert_eq!(w.sway_mask[4], 1.0);
359    }
360
361    #[test]
362    fn height_falloff_degenerate_range_is_zero() {
363        let positions = vec![[0.0, 1.0, 0.0], [0.0, 1.0, 0.0]];
364        let w = WindSwayWeights::height_falloff(&positions, 1.0, 1.0);
365        assert!(w.sway_mask.iter().all(|&m| m == 0.0));
366    }
367
368    fn try_make_device() -> Option<(wgpu::Device, wgpu::Queue)> {
369        let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());
370        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
371            power_preference: wgpu::PowerPreference::LowPower,
372            compatible_surface: None,
373            force_fallback_adapter: false,
374        }))
375        .ok()?;
376        pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()
377    }
378
379    #[test]
380    fn install_registers_deformer_on_a_host_slot() {
381        let Some((device, _queue)) = try_make_device() else {
382            eprintln!("skipping: no wgpu adapter available");
383            return;
384        };
385        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Bgra8UnormSrgb, 1);
386        // One internal registration (skinning) is present at construction.
387        let baseline = resources.registered_deformer_count();
388
389        let wind = WindPlugin::new(WindAuthoring::default());
390        let id = wind.install(&mut resources, &device).expect("install");
391
392        assert!(id.slot() < viewport_lib::DEFORM_SLOT_COUNT);
393        assert_eq!(resources.registered_deformer_count(), baseline + 1);
394    }
395
396    #[test]
397    fn attach_material_params_writes_per_instance_slot_data() {
398        let Some((device, queue)) = try_make_device() else {
399            eprintln!("skipping: no wgpu adapter available");
400            return;
401        };
402        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Bgra8UnormSrgb, 1);
403        let plane = viewport_lib::geometry::primitives::grid_plane(1.0, 1.0, 4, 4);
404        let mesh_id = resources.upload_mesh_data(&device, &plane).unwrap();
405
406        let wind = WindPlugin::new(WindAuthoring::default());
407        let id = wind.install(&mut resources, &device).expect("install");
408
409        let params = WindMaterialParams {
410            strength: 0.2,
411            density: 3.0,
412            speed: 0.5,
413        };
414        assert!(wind.attach_material_params(&mut resources, &device, &queue, mesh_id, &params));
415        assert!(resources.has_deform_slot_instance(mesh_id, 0, id.slot()));
416    }
417
418    #[test]
419    fn attach_sway_mask_marks_slot_active_for_mesh() {
420        let Some((device, _queue)) = try_make_device() else {
421            eprintln!("skipping: no wgpu adapter available");
422            return;
423        };
424        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Bgra8UnormSrgb, 1);
425        let plane = viewport_lib::geometry::primitives::grid_plane(1.0, 1.0, 4, 4);
426        let mesh_id = resources.upload_mesh_data(&device, &plane).unwrap();
427
428        let wind = WindPlugin::new(WindAuthoring::default());
429        let id = wind.install(&mut resources, &device).expect("install");
430
431        let mask = WindSwayWeights::uniform(plane.positions.len(), 1.0);
432        assert!(wind.attach_sway_mask(&mut resources, &device, mesh_id, &mask));
433        assert!(resources.has_deform_slot(mesh_id, id.slot()));
434
435        assert!(wind.detach_sway_mask(&mut resources, &device, mesh_id));
436        assert!(!resources.has_deform_slot(mesh_id, id.slot()));
437    }
438
439    #[test]
440    fn plugin_field_round_trips_authoring() {
441        let auth = WindAuthoring {
442            direction: Vec3::Z,
443            base_strength: 0.8,
444            gust_strength: 0.0,
445            gust_frequency: 0.0,
446            spatial_density: 0.0,
447        };
448        let p = WindPlugin::new(auth);
449        let sample = p.field().sample(Vec3::ZERO);
450        assert!((sample - Vec3::new(0.0, 0.0, 0.8)).length() < 1e-6);
451    }
452}