Skip to main content

clustered_decals/
clustered_decals.rs

1//! Demonstrates clustered decals, which affix decals to surfaces.
2
3use std::f32::consts::{FRAC_PI_3, PI};
4use std::fmt::{self, Formatter};
5
6use bevy::feathers::dark_theme::create_dark_theme;
7use bevy::ui_widgets::radio_self_update;
8use bevy::{
9    color::palettes::css::{LIME, ORANGE_RED, SILVER},
10    feathers::{
11        controls::{FeathersNumberInput, NumberInputPrecision, NumberInputValue},
12        theme::UiTheme,
13        FeathersPlugins,
14    },
15    input::mouse::AccumulatedMouseMotion,
16    light::ClusteredDecal,
17    pbr::{decal, ExtendedMaterial, MaterialExtension},
18    prelude::*,
19    render::{
20        render_resource::AsBindGroup,
21        renderer::{RenderAdapter, RenderDevice},
22    },
23    shader::ShaderRef,
24    ui_widgets::ValueChange,
25};
26use ops::{acos, cos, sin};
27
28#[path = "../helpers/radio.rs"]
29mod radio;
30
31#[path = "../helpers/number_input_f32.rs"]
32mod number_input_f32;
33
34use number_input_f32::number_input_f32;
35
36/// The custom material shader that we use to demonstrate how to use the decal
37/// `tag` field.
38const SHADER_ASSET_PATH: &str = "shaders/custom_clustered_decal.wesl";
39
40/// The speed at which the cube rotates, in radians per frame.
41const CUBE_ROTATION_SPEED: f32 = 0.02;
42
43/// The speed at which the selection can be moved, in spherical coordinate
44/// radians per mouse unit.
45const MOVE_SPEED: f32 = 0.008;
46
47/// Various settings for the demo.
48#[derive(Resource, Default)]
49struct AppStatus {
50    /// The object that will be moved, scaled, or rotated when the
51    /// mouse is dragged.
52    selection: Selection,
53}
54
55/// The object that will be moved, scaled, or rotated when the mouse is dragged.
56#[derive(Clone, Copy, Component, Default, PartialEq)]
57enum Selection {
58    /// The camera.
59    ///
60    /// The camera can only be moved, not scaled or rotated.
61    #[default]
62    Camera,
63    /// The first decal, which an orange bounding box surrounds.
64    DecalA,
65    /// The second decal, which a lime green bounding box surrounds.
66    DecalB,
67}
68
69impl fmt::Display for Selection {
70    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
71        match *self {
72            Selection::Camera => f.write_str("camera"),
73            Selection::DecalA => f.write_str("decal A"),
74            Selection::DecalB => f.write_str("decal B"),
75        }
76    }
77}
78
79/// Indicates which aspect of the decal the `FeathersNumberInput` in the app influences.
80#[derive(Clone, Copy, Component, Default, PartialEq, Debug)]
81enum AppNumberInput {
82    /// The scale (size) of the selected decal.
83    #[default]
84    Scale,
85    /// The roll (rotation) of the selected decal.
86    Roll,
87}
88
89/// A component that stores the base scale of a clustered decal.
90#[derive(Clone, Copy, Component, Default, PartialEq, Debug)]
91struct BaseScale(Vec3);
92
93/// A marker component for the help text in the top left corner of the window.
94#[derive(Clone, Copy, Component)]
95struct HelpText;
96
97/// A shader extension that demonstrates how to use the `tag` field to customize
98/// the appearance of your decals.
99#[derive(Asset, AsBindGroup, Reflect, Debug, Clone)]
100struct CustomDecalExtension {}
101
102impl MaterialExtension for CustomDecalExtension {
103    fn fragment_shader() -> ShaderRef {
104        SHADER_ASSET_PATH.into()
105    }
106}
107
108/// Entry point.
109fn main() {
110    App::new()
111        .insert_resource(UiTheme(create_dark_theme()))
112        .add_plugins((
113            DefaultPlugins.set(WindowPlugin {
114                primary_window: Some(Window {
115                    title: "Bevy Clustered Decals Example".into(),
116                    ..default()
117                }),
118                ..default()
119            }),
120            FeathersPlugins,
121        ))
122        .add_plugins(MaterialPlugin::<
123            ExtendedMaterial<StandardMaterial, CustomDecalExtension>,
124        >::default())
125        .init_resource::<AppStatus>()
126        .add_systems(Startup, setup)
127        .add_systems(Update, draw_gizmos)
128        .add_systems(Update, rotate_cube)
129        .add_systems(Update, update_help_text)
130        .add_observer(handle_drag_as_movement)
131        .add_observer(handle_selection_change)
132        .add_observer(handle_value_change_number_input)
133        .add_observer(radio_self_update)
134        .run();
135}
136
137/// Creates the scene.
138fn setup(
139    mut commands: Commands,
140    asset_server: Res<AssetServer>,
141    app_status: Res<AppStatus>,
142    render_device: Res<RenderDevice>,
143    render_adapter: Res<RenderAdapter>,
144    mut meshes: ResMut<Assets<Mesh>>,
145    mut materials: ResMut<Assets<ExtendedMaterial<StandardMaterial, CustomDecalExtension>>>,
146) {
147    // Error out if clustered decals aren't supported on the current platform.
148    if !decal::clustered::clustered_decals_are_usable(&render_device, &render_adapter) {
149        error!("Clustered decals aren't usable on this platform.");
150        commands.write_message(AppExit::error());
151    }
152
153    spawn_cube(&mut commands, &mut meshes, &mut materials);
154    spawn_camera(&mut commands);
155    spawn_light(&mut commands);
156    spawn_decals(&mut commands, &asset_server);
157    spawn_buttons(&mut commands);
158    spawn_help_text(&mut commands, &app_status);
159}
160
161/// Spawns the cube onto which the decals are projected.
162fn spawn_cube(
163    commands: &mut Commands,
164    meshes: &mut Assets<Mesh>,
165    materials: &mut Assets<ExtendedMaterial<StandardMaterial, CustomDecalExtension>>,
166) {
167    // Rotate the cube a bit just to make it more interesting.
168    let mut transform = Transform::IDENTITY;
169    transform.rotate_y(FRAC_PI_3);
170
171    commands.spawn((
172        Mesh3d(meshes.add(Cuboid::new(3.0, 3.0, 3.0))),
173        MeshMaterial3d(materials.add(ExtendedMaterial {
174            base: StandardMaterial {
175                base_color: SILVER.into(),
176                ..default()
177            },
178            extension: CustomDecalExtension {},
179        })),
180        transform,
181    ));
182}
183
184/// Spawns the directional light.
185fn spawn_light(commands: &mut Commands) {
186    commands.spawn((
187        DirectionalLight::default(),
188        Transform::from_xyz(4.0, 8.0, 4.0).looking_at(Vec3::ZERO, Vec3::Y),
189    ));
190}
191
192/// Spawns the camera.
193fn spawn_camera(commands: &mut Commands) {
194    commands
195        .spawn(Camera3d::default())
196        .insert(Transform::from_xyz(0.0, 2.5, 9.0).looking_at(Vec3::ZERO, Vec3::Y))
197        // Tag the camera with `Selection::Camera`.
198        .insert(Selection::Camera);
199}
200
201/// Spawns the actual clustered decals.
202fn spawn_decals(commands: &mut Commands, asset_server: &AssetServer) {
203    let base_color_texture = asset_server.load("branding/icon.png");
204
205    commands.spawn((
206        ClusteredDecal {
207            base_color_texture: Some(base_color_texture.clone()),
208            // Tint with red.
209            tag: 1,
210            ..ClusteredDecal::default()
211        },
212        calculate_initial_decal_transform(vec3(1.0, 3.0, 5.0), Vec3::ZERO, Vec2::splat(1.1)),
213        Selection::DecalA,
214    ));
215
216    commands.spawn((
217        ClusteredDecal {
218            base_color_texture: Some(base_color_texture.clone()),
219            // Tint with blue.
220            tag: 2,
221            ..ClusteredDecal::default()
222        },
223        calculate_initial_decal_transform(vec3(-2.0, -1.0, 4.0), Vec3::ZERO, Vec2::splat(2.0)),
224        Selection::DecalB,
225    ));
226}
227
228/// Spawns the buttons at the bottom of the screen.
229fn spawn_buttons(commands: &mut Commands) {
230    // Spawn the radio buttons that allow the user to select an object to
231    // control, and the number inputs that allow the user to alter additional
232    // aspects of clustered decals.
233    commands.spawn_scene(bsn! {
234        @radio::main_ui_node_scene()
235        Children [
236            @radio::feathers_option_buttons("Drag to Move",
237            &[
238                (Selection::Camera, "Camera"),
239                (Selection::DecalA, "Decal A"),
240                (Selection::DecalB, "Decal B"),
241            ], 0)
242            --
243            // The number inputs start off hidden because Camera is selected first.
244            Visibility::Hidden
245            @number_input_f32("Scale Multiplier", Some(AppNumberInput::Scale), 1.0, NumberInputPrecision(2), 0.05..=10.)
246            --
247            Visibility::Hidden
248            // + epsilon and next_down are used since roll recalculation likes to switch between -PI and PI upon recalculating roll.
249            @number_input_f32("Roll (-π to π)", Some(AppNumberInput::Roll), 0.0, NumberInputPrecision(2), -PI + f32::EPSILON ..=PI.next_down())
250        ]
251    });
252}
253
254/// Observer that handles changes to number inputs.
255/// The number inputs affect the scale or rotation of the currently selected decal, if any.
256fn handle_value_change_number_input(
257    value_change: On<ValueChange<f32>>,
258    mut commands: Commands,
259    number_input_q: Query<&AppNumberInput, With<FeathersNumberInput>>,
260    app_status: ResMut<AppStatus>,
261    mut selections: Query<(&mut Transform, &BaseScale, &Selection)>,
262) {
263    if app_status.selection == Selection::Camera {
264        return;
265    }
266    if let Ok(app_number_input) = number_input_q.get(value_change.source) {
267        for (mut transform, base_scale, selection) in &mut selections {
268            if app_status.selection != *selection {
269                continue;
270            }
271            match app_number_input {
272                AppNumberInput::Scale => {
273                    transform.scale = base_scale.0 * value_change.value;
274                }
275                AppNumberInput::Roll => {
276                    let (yaw, pitch, mut _roll) = transform.rotation.to_euler(EulerRot::YXZ);
277                    // Keep yaw and pitch the same, but change the roll.
278                    transform.rotation =
279                        Quat::from_euler(EulerRot::YXZ, yaw, pitch, value_change.value);
280                }
281            }
282        }
283        commands
284            .entity(value_change.source)
285            .insert(NumberInputValue::F32(value_change.value));
286    }
287}
288
289/// Handles requests from the user to change the selected object to control and expose
290/// the appropriate controls.
291/// The `radio_self_update` observer handles setting the `Checked` state on the radio buttons.
292fn handle_selection_change(
293    event: On<ValueChange<Entity>>,
294    new_value_query: Query<&radio::RadioButtonOptionValue<Selection>>,
295    mut app_status: ResMut<AppStatus>,
296    mut commands: Commands,
297    selections: Query<(&Transform, &BaseScale, &Selection)>,
298    number_inputs: Query<(Entity, &ChildOf, &AppNumberInput)>,
299) {
300    let Ok(radio::RadioButtonOptionValue(selection)) = new_value_query.get(event.value) else {
301        return;
302    };
303    app_status.selection = *selection;
304
305    // Update the visibility of the scale and roll number inputs so that they aren't visible
306    // if the camera is selected.
307    for (input_entity, child_of, app_number_input) in number_inputs.iter() {
308        match app_status.selection {
309            Selection::Camera => {
310                commands
311                    .entity(child_of.parent())
312                    .insert(Visibility::Hidden);
313            }
314            Selection::DecalA | Selection::DecalB => {
315                commands
316                    .entity(child_of.parent())
317                    .insert(Visibility::Inherited);
318
319                // Update the input values to the correct ones for this decal.
320                for (transform, base_scale, _selection) in selections
321                    .iter()
322                    .filter(|&(_, _, selection)| *selection == app_status.selection)
323                {
324                    if AppNumberInput::Scale == *app_number_input {
325                        // Scale should be uniformly multiplied.
326                        let scale_multiplier = transform.scale.x / base_scale.0.x;
327                        commands
328                            .entity(input_entity)
329                            .insert(NumberInputValue::F32(scale_multiplier));
330                    } else {
331                        let roll = transform.rotation.to_euler(EulerRot::YXZ).2;
332                        commands
333                            .entity(input_entity)
334                            .insert(NumberInputValue::F32(roll));
335                    }
336                }
337            }
338        };
339    }
340}
341
342/// Spawns the help text at the top of the screen.
343fn spawn_help_text(commands: &mut Commands, app_status: &AppStatus) {
344    commands.spawn((
345        Text::new(create_help_string(app_status)),
346        Node {
347            position_type: PositionType::Absolute,
348            top: px(12),
349            left: px(12),
350            ..default()
351        },
352        HelpText,
353    ));
354}
355
356/// Draws the outlines that show the bounds of the clustered decals.
357fn draw_gizmos(
358    mut gizmos: Gizmos,
359    decals: Query<(&GlobalTransform, &Selection), With<ClusteredDecal>>,
360) {
361    for (global_transform, selection) in &decals {
362        let color = match *selection {
363            Selection::Camera => continue,
364            Selection::DecalA => ORANGE_RED,
365            Selection::DecalB => LIME,
366        };
367
368        gizmos.primitive_3d(
369            &Cuboid {
370                // Since the clustered decal is a 1×1×1 cube in model space, its
371                // half-size is half of the scaling part of its transform.
372                half_size: global_transform.scale() * 0.5,
373            },
374            Isometry3d {
375                rotation: global_transform.rotation(),
376                translation: global_transform.translation_vec3a(),
377            },
378            color,
379        );
380    }
381}
382
383/// Calculates the initial transform of the clustered decal.
384fn calculate_initial_decal_transform(start: Vec3, looking_at: Vec3, size: Vec2) -> impl Bundle {
385    let direction = looking_at - start;
386    let center = start + direction * 0.5;
387    let base_scale = (size * 0.5).extend(direction.length());
388    (
389        Transform::from_translation(center)
390            .with_scale(base_scale)
391            .looking_to(direction, Vec3::Y),
392        BaseScale(base_scale),
393    )
394}
395
396/// Rotates the cube a bit every frame.
397fn rotate_cube(mut meshes: Query<&mut Transform, With<Mesh3d>>) {
398    for mut transform in &mut meshes {
399        transform.rotate_y(CUBE_ROTATION_SPEED);
400    }
401}
402
403/// Process a drag event that moves the selected object.
404fn handle_drag_as_movement(
405    event: On<PointerDrag>,
406    parent_q: Query<&ChildOf>,
407    number_input_q: Query<(), With<FeathersNumberInput>>,
408    mut selections: Query<(&mut Transform, &Selection)>,
409    mouse_motion: Res<AccumulatedMouseMotion>,
410    app_status: Res<AppStatus>,
411) {
412    // If we are currently dragging the number input, do not interpret it as movement
413    // of the selection.
414    if parent_q
415        .iter_ancestors(event.entity)
416        .any(|parent| number_input_q.contains(parent))
417    {
418        return;
419    }
420    for (mut transform, selection) in &mut selections {
421        if app_status.selection != *selection {
422            continue;
423        }
424
425        let position = transform.translation;
426
427        // Convert to spherical coordinates.
428        let radius = position.length();
429        let mut theta = acos(position.y / radius);
430        let mut phi = position.z.signum() * acos(position.x * position.xz().length_recip());
431
432        // Camera movement is the inverse of object movement.
433        let (phi_factor, theta_factor) = match *selection {
434            Selection::Camera => (1.0, -1.0),
435            Selection::DecalA | Selection::DecalB => (-1.0, 1.0),
436        };
437
438        // Adjust the spherical coordinates. Clamp the inclination to (0, π).
439        phi += phi_factor * mouse_motion.delta.x * MOVE_SPEED;
440        theta = f32::clamp(
441            theta + theta_factor * mouse_motion.delta.y * MOVE_SPEED,
442            0.001,
443            PI - 0.001,
444        );
445
446        // Convert spherical coordinates back to Cartesian coordinates.
447        transform.translation =
448            radius * vec3(sin(theta) * cos(phi), cos(theta), sin(theta) * sin(phi));
449
450        // Look at the center, but preserve the previous roll angle.
451        let roll = transform.rotation.to_euler(EulerRot::YXZ).2;
452        transform.look_at(Vec3::ZERO, Vec3::Y);
453        let (yaw, pitch, _) = transform.rotation.to_euler(EulerRot::YXZ);
454        transform.rotation = Quat::from_euler(EulerRot::YXZ, yaw, pitch, roll);
455    }
456}
457
458/// Creates the help string at the top left of the screen.
459fn create_help_string(app_status: &AppStatus) -> String {
460    if app_status.selection == Selection::Camera {
461        format!("Click and drag to move {}.", app_status.selection)
462    } else {
463        format!(
464            "Click and drag to move/scale/rotate {}.\n\
465            To scale/rotate, start the drag within the corresponding number input.\n\
466            To move, start the drag anywhere else in the example.",
467            app_status.selection
468        )
469    }
470}
471
472/// Updates the help text in the top left of the screen to reflect the current
473/// selection.
474fn update_help_text(mut help_text: Query<&mut Text, With<HelpText>>, app_status: Res<AppStatus>) {
475    for mut text in &mut help_text {
476        text.0 = create_help_string(&app_status);
477    }
478}