From 0d1b98d40681c6c07a536d3895451978691bdb67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lex=20Rom=C3=A1n=20N=C3=BA=C3=B1ez?= Date: Sat, 26 Sep 2026 17:55:35 +0000 Subject: [PATCH] Implement filled gizmos (#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewed-on: https://git.eirexe.moe/EIREXE/shinespark/pulls/70 Co-authored-by: Álex Román Núñez --- src/fighter/debug.rs | 64 ++++--- src/filled_gizmo.wgsl | 30 +++ src/filled_gizmos.rs | 417 ++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 4 + 4 files changed, 492 insertions(+), 23 deletions(-) create mode 100644 src/filled_gizmo.wgsl create mode 100644 src/filled_gizmos.rs diff --git a/src/fighter/debug.rs b/src/fighter/debug.rs index 625357d..57f8a0f 100644 --- a/src/fighter/debug.rs +++ b/src/fighter/debug.rs @@ -1,10 +1,9 @@ //! Fighter debug views: gizmos and the egui inspector window. -use std::sync::OnceLock; -use bevy::color::palettes::css::ORANGE; -use bevy::{color::palettes::css::YELLOW, prelude::*}; -use bevy_egui::{EguiContexts, egui}; +use bevy::color::palettes::css::{ORANGE, ORANGE_RED, WHITE}; +use bevy::prelude::*; +use bevy_egui::{egui, EguiContexts}; use crate::debug_tools::{DebugSettings, FighterDebugRowKind}; use crate::fighter::animation::FighterAnimationFrame; @@ -15,14 +14,14 @@ use crate::fighter::attack::{ use crate::fighter::baked_animation::{BakedFighterAnimations, FighterBoneMatrices}; use crate::fighter::ecb::FighterPreviousECB; use crate::fighter::errors::FighterErrors; -use crate::fighter::hurtbox::FixedAffineCapsule; use crate::fighter::manifest::FighterManifest; -use crate::fighter::state::{FighterState, air}; +use crate::fighter::state::{air, FighterState}; use crate::fighter::visual::FighterAnimations; use crate::fighter::{ Fighter, FighterECB, FighterFacingDirection, FighterHitboxes, FighterPreviousTranslation, FighterTranslation, FighterVelocity, }; +use crate::filled_gizmos::FilledGizmos; use crate::game_settings::GameSettings; use crate::input::FighterInput; use crate::math::int::FGi32; @@ -38,6 +37,7 @@ pub fn debug_draw_ecb( &FighterPreviousTranslation, )>, mut gizmos: Gizmos, + mut filled_gizmos: FilledGizmos, ) { for (ecb, trf, ecb_prev, trf_prev) in ecbs { gizmos.cross( @@ -46,15 +46,18 @@ pub fn debug_draw_ecb( bevy::color::palettes::css::BLUE, ); + let prev_transformed = ecb_prev.to_3d_lineloop(Transform::from_xyz( + trf_prev.x.to_num::(), + trf_prev.y.to_num(), + 0.0, + )); + filled_gizmos.polygon(&prev_transformed, ORANGE_RED); + let transformed = ecb.to_3d_lineloop(Transform::from_xyz(trf.x.to_num(), trf.y.to_num(), 0.0)); - gizmos.lineloop(transformed, YELLOW); - let prev_transformed = ecb_prev.to_3d_lineloop(Transform::from_xyz( - trf_prev.x.to_num(), - trf_prev.y.to_num(), - -0.1, - )); - gizmos.lineloop(prev_transformed, ORANGE); + filled_gizmos.polygon(&transformed, ORANGE); + gizmos.lineloop(transformed, WHITE); + } } pub fn fighter_debug( @@ -183,15 +186,23 @@ pub fn animation_debug( } } -pub fn hurtbox_debug(query: Query<&FighterSolvedHurtboxes>, mut gizmos: Gizmos) { +pub fn hurtbox_debug( + query: Query<&FighterSolvedHurtboxes>, + mut filled_gizmos: FilledGizmos, +) { for hurtboxes in &query { for hurtbox in &hurtboxes.0 { - draw_affine_capsule(&mut gizmos, hurtbox.capsule, YELLOW); + filled_gizmos.affine_capsule( + hurtbox.capsule.transform.to_mat4(), + hurtbox.capsule.radius.to_num(), + hurtbox.capsule.half_length.to_num(), + Color::srgba(1.0, 1.0, 0.0, 0.75), + ); } } } -fn draw_affine_capsule( +/*fn draw_affine_capsule( gizmos: &mut Gizmos, capsule: FixedAffineCapsule, color: impl Into + Copy, @@ -213,9 +224,9 @@ fn draw_affine_capsule( for &(start, end) in &wireframe.lines { gizmos.line(vertices[start], vertices[end], color); } -} +}*/ -struct CapsuleWireVertex { +/*struct CapsuleWireVertex { direction: Vec3, center_sign: f32, } @@ -223,9 +234,9 @@ struct CapsuleWireVertex { struct CapsuleWireframe { vertices: Vec, lines: Vec<(usize, usize)>, -} +}*/ -fn capsule_wireframe() -> &'static CapsuleWireframe { +/*fn capsule_wireframe() -> &'static CapsuleWireframe { const RESOLUTION: usize = 16; const HEMISPHERE_STEPS: usize = 4; static WIREFRAME: OnceLock = OnceLock::new(); @@ -306,7 +317,7 @@ fn capsule_wireframe() -> &'static CapsuleWireframe { CapsuleWireframe { vertices, lines } }) -} +}*/ #[cfg(test)] mod capsule_debug_tests { @@ -326,7 +337,7 @@ mod capsule_debug_tests { assert!(marker.abs_diff_eq(Vec3::new(3.0, 4.0, 3.0), 1e-5)); } - #[test] + /*#[test] fn capsule_wireframe_caps_extend_in_opposite_directions() { let wireframe = capsule_wireframe(); let lower: Vec<_> = wireframe @@ -344,7 +355,7 @@ mod capsule_debug_tests { assert!(upper.iter().all(|vertex| vertex.direction.y >= 0.0)); assert!(lower.iter().any(|vertex| vertex.direction == Vec3::NEG_Y)); assert!(upper.iter().any(|vertex| vertex.direction == Vec3::Y)); - } + }*/ } pub fn attack_debug( @@ -356,6 +367,7 @@ pub fn attack_debug( &FighterAnimationFrame, )>, mut gizmos: Gizmos, + mut filled_gizmos: FilledGizmos, ) { for (hitboxes, _matrices, _translation, _facing_direction, _frame) in query { if let Some(active_attack) = &hitboxes.0 { @@ -382,6 +394,12 @@ pub fn attack_debug( capsule_draw_trf.to_isometry(), bevy::color::palettes::css::RED, ); + filled_gizmos.capsule( + capsule_draw_trf.to_isometry(), + solved_hitbox.capsule.radius.to_num(), + length * 0.5, + Color::srgba(1.0, 0.0, 0.0, 0.18), + ); gizmos.cross(start, 1.0, bevy::color::palettes::css::GREEN); gizmos.cross(end, 1.0, bevy::color::palettes::css::GREEN); diff --git a/src/filled_gizmo.wgsl b/src/filled_gizmo.wgsl new file mode 100644 index 0000000..5972072 --- /dev/null +++ b/src/filled_gizmo.wgsl @@ -0,0 +1,30 @@ +#import bevy_pbr::mesh_functions::{get_world_from_local, mesh_position_local_to_clip} + +@group(#{MATERIAL_BIND_GROUP}) @binding(0) var material_color: vec4; + +struct Vertex { + @builtin(instance_index) instance_index: u32, + @location(0) position: vec3, + @location(1) color: vec4, +}; + +struct VertexOutput { + @builtin(position) clip_position: vec4, + @location(0) color: vec4, +}; + +@vertex +fn vertex(vertex: Vertex) -> VertexOutput { + var output: VertexOutput; + output.clip_position = mesh_position_local_to_clip( + get_world_from_local(vertex.instance_index), + vec4(vertex.position, 1.0), + ); + output.color = vertex.color; + return output; +} + +@fragment +fn fragment(input: VertexOutput) -> @location(0) vec4 { + return material_color * input.color; +} diff --git a/src/filled_gizmos.rs b/src/filled_gizmos.rs new file mode 100644 index 0000000..fd801fb --- /dev/null +++ b/src/filled_gizmos.rs @@ -0,0 +1,417 @@ +//! Immediate-mode filled debug geometry. +//! +//! Unlike Bevy's line-only `Gizmos`, this collects triangles into one dynamic +//! mesh. It is intended for small, transient debug overlays rather than game +//! geometry. + +use std::f32::consts::{FRAC_PI_2, PI, TAU}; + +use bevy::{ + asset::{load_internal_asset, uuid_handle, RenderAssetUsages}, + camera::visibility::NoFrustumCulling, + ecs::system::SystemParam, + mesh::{Indices, MeshVertexBufferLayoutRef, PrimitiveTopology}, + pbr::{MaterialPipeline, MaterialPipelineKey}, + prelude::*, + render::render_resource::{ + AsBindGroup, RenderPipelineDescriptor, SpecializedMeshPipelineError, + }, + shader::ShaderRef, +}; + +const SPHERE_SECTORS: usize = 16; +const SPHERE_STACKS: usize = 8; +const CAPSULE_SECTORS: usize = 16; +const CAPSULE_HEMISPHERE_STEPS: usize = 4; + +#[derive(Default)] +struct FilledGizmoGeometry { + positions: Vec<[f32; 3]>, + normals: Vec<[f32; 3]>, + colors: Vec<[f32; 4]>, + indices: Vec, +} + +#[derive(Resource, Default)] +struct FilledGizmoBuffer { + /// Geometry requested by the current schedule invocation. + pending: FilledGizmoGeometry, + /// The last completed fixed-timestep geometry. This is retained between + /// render frames, just like Bevy's fixed-timestep gizmos. + retained: FilledGizmoGeometry, + mesh: Option>, + fixed_commit: bool, +} + +/// Immediate-mode API for drawing filled debug shapes. +#[derive(SystemParam)] +pub struct FilledGizmos<'w> { + buffer: ResMut<'w, FilledGizmoBuffer>, +} + +impl FilledGizmos<'_> { + /// Draw a filled planar polygon. Vertices must be ordered around the + /// perimeter and the polygon must be convex. + pub fn polygon(&mut self, vertices: &[Vec3], color: impl Into + Copy) { + if vertices.len() < 3 { + return; + } + let color = color.into().to_linear().to_f32_array(); + let normal = (vertices[1] - vertices[0]) + .cross(vertices[2] - vertices[0]) + .normalize_or_zero(); + let base = self.buffer.pending.positions.len() as u32; + for &vertex in vertices { + self.push_vertex(vertex, normal, color); + } + for index in 2..vertices.len() as u32 { + self.buffer + .pending + .indices + .extend([base, base + index - 1, base + index]); + } + } + + pub fn sphere(&mut self, isometry: Isometry3d, radius: f32, color: impl Into + Copy) { + if radius <= 0.0 { + return; + } + let color = color.into().to_linear().to_f32_array(); + let base = self.buffer.pending.positions.len() as u32; + for stack in 0..=SPHERE_STACKS { + let latitude = PI * 0.5 - stack as f32 * PI / SPHERE_STACKS as f32; + let (sin_lat, cos_lat) = latitude.sin_cos(); + for sector in 0..=SPHERE_SECTORS { + let longitude = sector as f32 * TAU / SPHERE_SECTORS as f32; + let (sin_lon, cos_lon) = longitude.sin_cos(); + let normal = Vec3::new(cos_lat * cos_lon, sin_lat, cos_lat * sin_lon); + self.push_vertex( + isometry.transform_point(normal * radius).into(), + isometry.rotation * normal, + color, + ); + } + } + let stride = SPHERE_SECTORS + 1; + for stack in 0..SPHERE_STACKS { + for sector in 0..SPHERE_SECTORS { + let a = base + (stack * stride + sector) as u32; + let b = a + stride as u32; + self.buffer + .pending + .indices + .extend([a, b, a + 1, a + 1, b, b + 1]); + } + } + } + + pub fn capsule( + &mut self, + isometry: Isometry3d, + radius: f32, + half_length: f32, + color: impl Into + Copy, + ) { + let transform = Mat4::from_scale_rotation_translation( + Vec3::ONE, + isometry.rotation, + isometry.translation.into(), + ); + self.affine_capsule(transform, radius, half_length, color); + } + + /// Draw a capsule transformed by an arbitrary affine transform. + pub fn affine_capsule( + &mut self, + transform: Mat4, + radius: f32, + half_length: f32, + color: impl Into + Copy, + ) { + if radius <= 0.0 { + return; + } + let color = color.into().to_linear().to_f32_array(); + let base = self.buffer.pending.positions.len() as u32; + let mut rings = Vec::new(); + for step in 1..=CAPSULE_HEMISPHERE_STEPS { + let angle = -FRAC_PI_2 + step as f32 * FRAC_PI_2 / CAPSULE_HEMISPHERE_STEPS as f32; + rings.push(( + radius * angle.cos(), + -half_length + radius * angle.sin(), + angle.sin(), + )); + } + rings.push((radius, half_length, 0.0)); + for step in 1..CAPSULE_HEMISPHERE_STEPS { + let angle = step as f32 * FRAC_PI_2 / CAPSULE_HEMISPHERE_STEPS as f32; + rings.push(( + radius * angle.cos(), + half_length + radius * angle.sin(), + angle.sin(), + )); + } + + let mut ring_indices = Vec::new(); + let bottom_pole = self.push_capsule_vertex( + &mut ring_indices, + transform, + Vec3::NEG_Y, + -half_length, + radius, + color, + ); + for &(ring_radius, y, normal_y) in &rings { + let mut ring = Vec::with_capacity(CAPSULE_SECTORS); + for sector in 0..CAPSULE_SECTORS { + let angle = sector as f32 * TAU / CAPSULE_SECTORS as f32; + let (sin, cos) = angle.sin_cos(); + let normal = Vec3::new( + cos * (1.0 - normal_y * normal_y).sqrt(), + normal_y, + sin * (1.0 - normal_y * normal_y).sqrt(), + ); + ring.push(self.push_transformed_vertex( + transform, + Vec3::new(cos * ring_radius, y, sin * ring_radius), + normal, + color, + )); + } + ring_indices.push(ring); + } + let top_pole = self.push_capsule_vertex( + &mut ring_indices, + transform, + Vec3::Y, + half_length, + radius, + color, + ); + for sector in 0..CAPSULE_SECTORS { + let next = (sector + 1) % CAPSULE_SECTORS; + self.buffer.pending.indices.extend([ + bottom_pole, + ring_indices[0][next], + ring_indices[0][sector], + ]); + for ring in ring_indices.windows(2) { + self.buffer.pending.indices.extend([ + ring[0][sector], + ring[0][next], + ring[1][sector], + ring[1][sector], + ring[0][next], + ring[1][next], + ]); + } + let last = ring_indices.len() - 1; + self.buffer.pending.indices.extend([ + ring_indices[last][sector], + ring_indices[last][next], + top_pole, + ]); + } + // Keep the base used by this shape observable to avoid accidental + // future changes making the generated indices refer to another shape. + debug_assert!(base <= bottom_pole); + } + + fn push_capsule_vertex( + &mut self, + _rings: &mut Vec>, + transform: Mat4, + normal: Vec3, + y: f32, + radius: f32, + color: [f32; 4], + ) -> u32 { + self.push_transformed_vertex( + transform, + Vec3::new(0.0, y + normal.y * radius, 0.0), + normal, + color, + ) + } + + fn push_transformed_vertex( + &mut self, + transform: Mat4, + position: Vec3, + normal: Vec3, + color: [f32; 4], + ) -> u32 { + let transformed_normal = transform + .inverse() + .transpose() + .transform_vector3(normal) + .normalize_or_zero(); + let index = self.buffer.pending.positions.len() as u32; + self.push_vertex( + transform.transform_point3(position), + transformed_normal, + color, + ); + index + } + + fn push_vertex(&mut self, position: Vec3, normal: Vec3, color: [f32; 4]) { + self.buffer.pending.positions.push(position.to_array()); + self.buffer.pending.normals.push(normal.to_array()); + self.buffer.pending.colors.push(color); + } +} + +pub struct FilledGizmosPlugin; + +const FILLED_GIZMO_SHADER_HANDLE: Handle = + uuid_handle!("0e4a979f-ea11-4e5a-91cd-19c89d4e9b53"); + +/// A deliberately minimal material: unlike `StandardMaterial`, its fragment +/// shader writes vertex colors directly and therefore bypasses PBR lighting +/// and camera tonemapping. +#[derive(Asset, AsBindGroup, Reflect, Clone, Debug)] +struct FilledGizmoMaterial { + #[uniform(0)] + color: LinearRgba, +} + +impl Material for FilledGizmoMaterial { + fn vertex_shader() -> ShaderRef { + FILLED_GIZMO_SHADER_HANDLE.clone().into() + } + + fn fragment_shader() -> ShaderRef { + FILLED_GIZMO_SHADER_HANDLE.clone().into() + } + + fn alpha_mode(&self) -> AlphaMode { + AlphaMode::Blend + } + + fn enable_prepass() -> bool { + false + } + + fn enable_shadows() -> bool { + false + } + + fn specialize( + _pipeline: &MaterialPipeline, + descriptor: &mut RenderPipelineDescriptor, + layout: &MeshVertexBufferLayoutRef, + _key: MaterialPipelineKey, + ) -> Result<(), SpecializedMeshPipelineError> { + descriptor.vertex.buffers = vec![layout.0.get_layout(&[ + Mesh::ATTRIBUTE_POSITION.at_shader_location(0), + Mesh::ATTRIBUTE_COLOR.at_shader_location(1), + ])?]; + descriptor.primitive.cull_mode = None; + Ok(()) + } +} + +impl Plugin for FilledGizmosPlugin { + fn build(&self, app: &mut App) { + load_internal_asset!( + app, + FILLED_GIZMO_SHADER_HANDLE, + "filled_gizmo.wgsl", + Shader::from_wgsl + ); + app.add_plugins(MaterialPlugin::::default()) + .init_resource::() + .add_systems(Startup, setup_filled_gizmo_mesh) + .add_systems(FixedFirst, begin_fixed_filled_gizmos) + .add_systems(FixedLast, commit_fixed_filled_gizmos) + .add_systems(Last, upload_filled_gizmo_mesh); + } +} + +fn setup_filled_gizmo_mesh( + mut commands: Commands, + mut meshes: ResMut>, + mut materials: ResMut>, + mut buffer: ResMut, +) { + let mesh = meshes.add( + Mesh::new( + PrimitiveTopology::TriangleList, + RenderAssetUsages::default(), + ) + .with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, vec![[0.0; 3]; 3]) + .with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, vec![[0.0, 0.0, 1.0]; 3]) + .with_inserted_attribute(Mesh::ATTRIBUTE_COLOR, vec![[0.0, 0.0, 0.0, 0.0]; 3]) + .with_inserted_indices(Indices::U32(vec![0, 1, 2])), + ); + let material = materials.add(FilledGizmoMaterial { + color: LinearRgba::WHITE, + }); + commands.spawn(( + Mesh3d(mesh.clone()), + MeshMaterial3d(material), + NoFrustumCulling, + )); + buffer.mesh = Some(mesh); +} + +fn begin_fixed_filled_gizmos(mut buffer: ResMut) { + buffer.pending = FilledGizmoGeometry::default(); + buffer.fixed_commit = false; +} + +fn commit_fixed_filled_gizmos(mut buffer: ResMut) { + let pending = std::mem::take(&mut buffer.pending); + buffer.pending = std::mem::replace(&mut buffer.retained, pending); + buffer.fixed_commit = true; +} + +fn upload_filled_gizmo_mesh( + mut meshes: ResMut>, + mut buffer: ResMut, +) { + // No fixed tick and no render-frame requests means the existing mesh is + // already the correct image. In particular, do not replace it with the + // empty placeholder between fixed updates. + if !buffer.fixed_commit && buffer.pending.positions.is_empty() { + return; + } + let Some(handle) = buffer.mesh.clone() else { + return; + }; + + let (positions, normals, colors, indices) = if buffer.fixed_commit { + // Retained geometry must remain in the CPU-side buffer after upload; + // otherwise the next render frame would have nothing to draw. + ( + buffer.retained.positions.clone(), + buffer.retained.normals.clone(), + buffer.retained.colors.clone(), + buffer.retained.indices.clone(), + ) + } else { + ( + std::mem::take(&mut buffer.pending.positions), + std::mem::take(&mut buffer.pending.normals), + std::mem::take(&mut buffer.pending.colors), + std::mem::take(&mut buffer.pending.indices), + ) + }; + + if let Some(mut mesh) = meshes.get_mut(&handle) { + if positions.is_empty() { + mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, vec![[0.0; 3]; 3]); + mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, vec![[0.0, 0.0, 1.0]; 3]); + mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, vec![[0.0, 0.0, 0.0, 0.0]; 3]); + mesh.insert_indices(Indices::U32(vec![0, 1, 2])); + } else { + mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions); + mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals); + mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, colors); + mesh.insert_indices(Indices::U32(indices)); + } + } + buffer.pending = FilledGizmoGeometry::default(); + buffer.fixed_commit = false; +} diff --git a/src/lib.rs b/src/lib.rs index 3d1a9b1..4233226 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,6 +21,8 @@ //! save, click Rebuild in jackdaw (or run `jd build`) and it //! appears in `Add Component`. No registration code is needed. +#![recursion_limit = "512"] + use bevy::{ asset::processor::{AssetProcessor, ProcessorState}, prelude::*, @@ -42,6 +44,7 @@ pub mod blend_asset; pub mod camera; pub mod debug_tools; pub mod fighter; +pub mod filled_gizmos; pub mod game_settings; pub mod input; pub mod match_loading; @@ -114,6 +117,7 @@ impl Plugin for GamePlugin { input::FighterInputPlugin, replay::ReplayPlugin, debug_tools::DebugToolsPlugin, + filled_gizmos::FilledGizmosPlugin, fighter::FighterPlugin, fighter::baked_animation::BakedAnimationPlugin, scripting::importer::FighterScriptImportPlugin,