render: implement sprite pivot, fix sprite centering in ortho projection

This commit is contained in:
SeanOMik 2024-11-27 23:04:20 -05:00
parent d1aee610cc
commit a06c065337
Signed by: SeanOMik
GPG Key ID: FEC9E2FC15235964
8 changed files with 111 additions and 90 deletions

View File

@ -17,20 +17,18 @@ use crate::{
render::{ render::{
graph::{Node, NodeDesc, NodeType, SlotAttribute}, graph::{Node, NodeDesc, NodeType, SlotAttribute},
resource::{FragmentState, RenderPipeline, RenderPipelineDescriptor, VertexState}, resource::{FragmentState, RenderPipeline, RenderPipelineDescriptor, VertexState},
transform_buffer_storage::{TransformBuffers, TransformIndex},
vertex::Vertex2D, vertex::Vertex2D,
}, },
sprite::{AtlasSprite, Sprite}, sprite::{AtlasSprite, Sprite},
}; };
use super::{BasePassSlots, RenderAssets}; use super::{BasePassSlots, InterpTransform, RenderAssets};
#[derive(Clone)] #[derive(Clone)]
pub struct RenderJob { pub struct RenderJob {
pub entity: Entity, pub entity: Entity,
pub shader_id: u64, pub shader_id: u64,
pub asset_uuid: uuid::Uuid, pub asset_uuid: uuid::Uuid,
pub transform_id: TransformIndex,
pub atlas_frame_id: u64, pub atlas_frame_id: u64,
} }
@ -64,11 +62,8 @@ pub struct SpritePass {
pipeline: Option<RenderPipeline>, pipeline: Option<RenderPipeline>,
texture_bgl: Option<Arc<wgpu::BindGroupLayout>>, texture_bgl: Option<Arc<wgpu::BindGroupLayout>>,
jobs: VecDeque<RenderJob>, jobs: VecDeque<RenderJob>,
/// Buffer that stores a `Rect` with `min` and `max` set to zero. sprite_instances_buf: Option<Arc<wgpu::Buffer>>,
/// This can be used for sprites that are not from an atlas.
atlas_frames_buf: Option<Arc<wgpu::Buffer>>,
transform_buffers: Option<ResourceData>,
texture_store: Option<ResourceData>, texture_store: Option<ResourceData>,
buffer_store: Option<ResourceData>, buffer_store: Option<ResourceData>,
} }
@ -175,7 +170,7 @@ impl SpritePass {
}, },
); );
let frames = self.atlas_frames_buf.as_ref().unwrap(); let sprite_instances = self.sprite_instances_buf.as_ref().unwrap();
let bgl = self.texture_bgl.as_ref().unwrap(); let bgl = self.texture_bgl.as_ref().unwrap();
let tex_bg = device.create_bind_group(&wgpu::BindGroupDescriptor { let tex_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some(&format!("sprite_texture_bg_{}", uuid_str)), label: Some(&format!("sprite_texture_bg_{}", uuid_str)),
@ -192,7 +187,7 @@ impl SpritePass {
wgpu::BindGroupEntry { wgpu::BindGroupEntry {
binding: 2, binding: 2,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &frames, buffer: &sprite_instances,
offset: 0, offset: 0,
size: None, size: None,
}), }),
@ -211,16 +206,13 @@ impl Node for SpritePass {
) -> crate::render::graph::NodeDesc { ) -> crate::render::graph::NodeDesc {
let device = &graph.device; let device = &graph.device;
let atlas_frames = device.create_buffer(&wgpu::BufferDescriptor { let sprite_instances = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("default_sprite_atlas_frame"), label: Some("sprite_instances"),
size: std::mem::size_of::<URect>() as u64 * 1000, size: std::mem::size_of::<SpriteInstance>() as u64 * 1000,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false, mapped_at_creation: false,
}); });
// write the rect for sprites that aren't part of the texture atlas. self.sprite_instances_buf = Some(Arc::new(sprite_instances));
graph
.queue
.write_buffer(&atlas_frames, 0, bytemuck::bytes_of(&URect::ZERO));
let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("bgl_sprite_main"), label: Some("bgl_sprite_main"),
@ -243,7 +235,7 @@ impl Node for SpritePass {
}, },
wgpu::BindGroupLayoutEntry { wgpu::BindGroupLayoutEntry {
binding: 2, binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT, visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer { ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true }, ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false, has_dynamic_offset: false,
@ -254,7 +246,6 @@ impl Node for SpritePass {
], ],
}); });
self.texture_bgl = Some(Arc::new(bgl)); self.texture_bgl = Some(Arc::new(bgl));
self.atlas_frames_buf = Some(Arc::new(atlas_frames));
let mut desc = NodeDesc::new(NodeType::Render, None, vec![]); let mut desc = NodeDesc::new(NodeType::Render, None, vec![]);
@ -282,16 +273,12 @@ impl Node for SpritePass {
let diffuse_bgl = self.texture_bgl.clone().unwrap(); let diffuse_bgl = self.texture_bgl.clone().unwrap();
let camera_bgl = graph.bind_group_layout(BasePassSlots::Camera).clone(); let camera_bgl = graph.bind_group_layout(BasePassSlots::Camera).clone();
let transforms = world
.get_resource::<TransformBuffers>()
.expect("Missing transform buffers");
let transform_bgl = transforms.bindgroup_layout.clone();
self.pipeline = Some(RenderPipeline::create( self.pipeline = Some(RenderPipeline::create(
device, device,
&RenderPipelineDescriptor { &RenderPipelineDescriptor {
label: Some("sprite_pass".into()), label: Some("sprite_pass".into()),
layouts: vec![diffuse_bgl, transform_bgl, camera_bgl], layouts: vec![diffuse_bgl, camera_bgl],
push_constant_ranges: vec![], push_constant_ranges: vec![],
vertex: VertexState { vertex: VertexState {
module: shader.clone(), module: shader.clone(),
@ -317,7 +304,6 @@ impl Node for SpritePass {
}, },
)); ));
drop(transforms);
world.add_resource_default_if_absent::<RenderAssets<SpriteTexture>>(); world.add_resource_default_if_absent::<RenderAssets<SpriteTexture>>();
let texture_store = world let texture_store = world
.get_resource_data::<RenderAssets<SpriteTexture>>().unwrap(); .get_resource_data::<RenderAssets<SpriteTexture>>().unwrap();
@ -327,22 +313,17 @@ impl Node for SpritePass {
let buffer_store = world let buffer_store = world
.get_resource_data::<FxHashMap<Entity, SpriteBuffers>>().unwrap(); .get_resource_data::<FxHashMap<Entity, SpriteBuffers>>().unwrap();
self.buffer_store = Some(buffer_store.clone()); self.buffer_store = Some(buffer_store.clone());
let transforms = world
.get_resource_data::<TransformBuffers>()
.expect("Missing transform buffers");
self.transform_buffers = Some(transforms.clone());
} }
let mut v = Vec::with_capacity(500); let mut sprite_instances = Vec::with_capacity(500);
let world_tick = world.current_tick(); let world_tick = world.current_tick();
let queue = &graph.queue; let queue = &graph.queue;
for (entity, (sprite, atlas_sprite), transform_idx, mut texture_store, mut buffer_store) in world for (entity, (sprite, atlas_sprite), interp_transform, mut texture_store, mut buffer_store) in world
.view::<( .view::<(
Entities, Entities,
Or<&Sprite, (&AtlasSprite, TickOf<AtlasSprite>)>, Or<&Sprite, (&AtlasSprite, TickOf<AtlasSprite>)>,
&TransformIndex, &InterpTransform,
ResMut<RenderAssets<SpriteTexture>>, ResMut<RenderAssets<SpriteTexture>>,
ResMut<FxHashMap<Entity, SpriteBuffers>>, ResMut<FxHashMap<Entity, SpriteBuffers>>,
)>() )>()
@ -374,13 +355,14 @@ impl Node for SpritePass {
} }
} }
if !buffer_store.contains_key(&entity) { let dim = rect
let dim = rect.unwrap_or_else(|| { .map(|r| r.dimensions())
.unwrap_or_else(|| {
let i = image.dimensions(); let i = image.dimensions();
URect::new(0, 0, i.0, i.1) UVec2::new(i.0, i.1)
}); });
debug!("storing rect: {dim:?}");
let dim = dim.dimensions(); if !buffer_store.contains_key(&entity) {
let (vertex, index) = self.create_vertex_index_buffers(device, dim); let (vertex, index) = self.create_vertex_index_buffers(device, dim);
buffer_store.insert(entity, SpriteBuffers { vertex_buffers: vertex, index_buffers: index }); buffer_store.insert(entity, SpriteBuffers { vertex_buffers: vertex, index_buffers: index });
} else if let Some((ats, tick)) = &atlas_sprite { } else if let Some((ats, tick)) = &atlas_sprite {
@ -393,29 +375,36 @@ impl Node for SpritePass {
} }
} }
let frame_id = match rect { let pivot = atlas_sprite.map(|ats| ats.0.pivot)
Some(r) => { // unwrap is safe since its either AtlasSprite or Sprite.
v.push(r); .unwrap_or_else(|| sprite.unwrap().pivot)
// No -1 here since the gpu buffer is already offset by 1 .as_vec();
// to store the default rect at the front
v.len() as u64 let pivot_pos = dim.as_vec2() * (pivot - Vec2::splat(0.5));
} let transform = interp_transform.last_transform
None => 0 + lyra_math::Transform::from_translation(Vec3::new(pivot_pos.x, pivot_pos.y, 0.0));
let inst = SpriteInstance {
atlas_frame: rect.unwrap_or(URect::ZERO),
transform: transform.calculate_mat4(),
pivot,
_padding: [0; 2],
}; };
sprite_instances.push(inst);
let inst_id = sprite_instances.len() as u64 - 1;
self.jobs.push_back(RenderJob { self.jobs.push_back(RenderJob {
entity, entity,
shader_id: 0, shader_id: 0,
asset_uuid: texture_uuid, asset_uuid: texture_uuid,
transform_id: *transform_idx, atlas_frame_id: inst_id,
atlas_frame_id: frame_id,
}); });
}; };
} }
let buf = self.atlas_frames_buf.as_ref().unwrap(); let buf = self.sprite_instances_buf.as_ref().unwrap();
// skip default rect // skip default rect
queue.write_buffer(buf, std::mem::size_of::<URect>() as _, bytemuck::cast_slice(&v)); queue.write_buffer(buf, 0, bytemuck::cast_slice(&sprite_instances));
} }
fn execute( fn execute(
@ -430,8 +419,6 @@ impl Node for SpritePass {
let texture_store: AtomicRef<RenderAssets<SpriteTexture>> = texture_store.get(); let texture_store: AtomicRef<RenderAssets<SpriteTexture>> = texture_store.get();
let buffer_store = self.buffer_store.clone().unwrap(); let buffer_store = self.buffer_store.clone().unwrap();
let buffer_store: AtomicRef<FxHashMap<Entity, SpriteBuffers>> = buffer_store.get(); let buffer_store: AtomicRef<FxHashMap<Entity, SpriteBuffers>> = buffer_store.get();
let transforms = self.transform_buffers.clone().unwrap();
let transforms: AtomicRef<TransformBuffers> = transforms.get();
let vt = graph.view_target(); let vt = graph.view_target();
let view = vt.render_view(); let view = vt.render_view();
@ -468,12 +455,7 @@ impl Node for SpritePass {
.expect("failed to find SpriteTexture for job asset_uuid"); .expect("failed to find SpriteTexture for job asset_uuid");
pass.set_bind_group(0, &tex.texture_bg, &[]); pass.set_bind_group(0, &tex.texture_bg, &[]);
// Get the bindgroup for job's transform and bind to it using an offset. pass.set_bind_group(1, camera_bg, &[]);
let bindgroup = transforms.bind_group(job.transform_id);
let offset = transforms.buffer_offset(job.transform_id);
pass.set_bind_group(1, bindgroup, &[offset]);
pass.set_bind_group(2, camera_bg, &[]);
// set vertex and index buffers // set vertex and index buffers
let bufs = buffer_store let bufs = buffer_store
@ -489,3 +471,12 @@ impl Node for SpritePass {
} }
} }
} }
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
struct SpriteInstance {
atlas_frame: URect,
transform: glam::Mat4,
pivot: glam::Vec2,
_padding: [u32; 2],
}

View File

@ -25,8 +25,8 @@ use crate::{
/// transform is updated less often than rendering. /// transform is updated less often than rendering.
#[derive(Clone, Debug, Component)] #[derive(Clone, Debug, Component)]
pub struct InterpTransform { pub struct InterpTransform {
last_transform: Transform, pub last_transform: Transform,
alpha: f32, pub alpha: f32,
} }
#[derive(Default, Debug, Clone, Copy, Hash, RenderGraphLabel)] #[derive(Default, Debug, Clone, Copy, Hash, RenderGraphLabel)]

View File

@ -23,6 +23,12 @@ struct URect {
max: vec2<u32>, max: vec2<u32>,
} }
struct SpriteInstance {
atlas_frame: URect,
transform: mat4x4<f32>,
pivot: vec2<f32>,
}
struct CameraUniform { struct CameraUniform {
view: mat4x4<f32>, view: mat4x4<f32>,
inverse_projection: mat4x4<f32>, inverse_projection: mat4x4<f32>,
@ -33,21 +39,21 @@ struct CameraUniform {
} }
@group(1) @binding(0) @group(1) @binding(0)
var<uniform> u_model_transform_data: TransformData;
@group(2) @binding(0)
var<uniform> u_camera: CameraUniform; var<uniform> u_camera: CameraUniform;
@vertex @vertex
fn vs_main( fn vs_main(
in: VertexInput, in: VertexInput,
) -> VertexOutput { ) -> VertexOutput {
let transform = u_sprite_instances[in.instance_index].transform;
var world_position: vec4<f32> = transform * vec4<f32>(in.position, 1.0);
var out: VertexOutput; var out: VertexOutput;
var world_position: vec4<f32> = u_model_transform_data.transform * vec4<f32>(in.position, 1.0);
out.world_position = world_position.xyz; out.world_position = world_position.xyz;
out.tex_coords = in.tex_coords; out.tex_coords = in.tex_coords;
out.clip_position = u_camera.view_projection * world_position; out.clip_position = u_camera.view_projection * world_position;
out.instance_index = in.instance_index; out.instance_index = in.instance_index;
return out; return out;
} }
@ -58,11 +64,12 @@ var t_diffuse: texture_2d<f32>;
var s_diffuse: sampler; var s_diffuse: sampler;
@group(0) @binding(2) @group(0) @binding(2)
var<storage, read> u_sprite_atlas_frames: array<URect>; var<storage, read> u_sprite_instances: array<SpriteInstance>;
@fragment @fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> { fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let frame = u_sprite_atlas_frames[in.instance_index]; let sprite = u_sprite_instances[in.instance_index];
let frame = sprite.atlas_frame;
var region_coords = in.tex_coords; var region_coords = in.tex_coords;
if (frame.min.x != 0 || frame.min.y != 0 || frame.max.x != 0 || frame.max.y != 0) { if (frame.min.x != 0 || frame.min.y != 0 || frame.max.x != 0 || frame.max.y != 0) {

View File

@ -34,6 +34,7 @@ impl Default for ScaleMode {
#[derive(Debug, Clone, Copy, PartialEq, Reflect)] #[derive(Debug, Clone, Copy, PartialEq, Reflect)]
pub struct OrthographicProjection { pub struct OrthographicProjection {
pub viewport_origin: Vec2,
pub scale_mode: ScaleMode, pub scale_mode: ScaleMode,
pub scale: f32, pub scale: f32,
pub znear: f32, pub znear: f32,
@ -43,6 +44,7 @@ pub struct OrthographicProjection {
impl Default for OrthographicProjection { impl Default for OrthographicProjection {
fn default() -> Self { fn default() -> Self {
Self { Self {
viewport_origin: Vec2::new(0.5, 0.5),
scale_mode: Default::default(), scale_mode: Default::default(),
scale: 1.0, scale: 1.0,
znear: 0.0, znear: 0.0,
@ -53,52 +55,44 @@ impl Default for OrthographicProjection {
impl OrthographicProjection { impl OrthographicProjection {
fn get_rect(&self, viewport_size: Vec2) -> Rect { fn get_rect(&self, viewport_size: Vec2) -> Rect {
let origin; //let origin;
let size; let size;
match self.scale_mode { match self.scale_mode {
ScaleMode::Viewport => { ScaleMode::Viewport => {
origin = viewport_size * 0.5;
size = viewport_size; size = viewport_size;
}, },
ScaleMode::Width(width) => { ScaleMode::Width(width) => {
let aspect = viewport_size.x / viewport_size.y; let aspect = viewport_size.x / viewport_size.y;
let origin_x = width * 0.5;
let scaled_height = width / aspect; let scaled_height = width / aspect;
let origin_y = scaled_height * 0.5;
origin = Vec2::new(origin_x, origin_y);
size = Vec2::new(width, scaled_height); size = Vec2::new(width, scaled_height);
}, },
ScaleMode::Height(height) => { ScaleMode::Height(height) => {
let aspect = viewport_size.x / viewport_size.y; let aspect = viewport_size.x / viewport_size.y;
let origin_y = height * 0.5;
let scaled_width = height * aspect; let scaled_width = height * aspect;
let origin_x = scaled_width * 0.5;
origin = Vec2::new(origin_x, origin_y);
size = Vec2::new(scaled_width, height); size = Vec2::new(scaled_width, height);
}, },
ScaleMode::Size(s) => { ScaleMode::Size(s) => {
origin = s * 0.5;
size = s; size = s;
}, },
ScaleMode::MaxSize(s) => { ScaleMode::MaxSize(s) => {
let clamped = s.min(viewport_size); let clamped = s.min(viewport_size);
origin = clamped * 0.5;
size = clamped; size = clamped;
}, },
ScaleMode::MinSize(s) => { ScaleMode::MinSize(s) => {
let clamped = s.max(viewport_size); let clamped = s.max(viewport_size);
origin = clamped * 0.5;
size = clamped; size = clamped;
} }
} }
let origin = size * self.viewport_origin;
Rect::new(self.scale * -origin.x, Rect::new(self.scale * -origin.x,
self.scale * -origin.y, self.scale * -origin.y,
self.scale * size.x - origin.x, self.scale * (size.x - origin.x),
self.scale * size.y - origin.y) self.scale * (size.y - origin.y))
} }
pub fn to_mat(&self, viewport_size: Vec2) -> Mat4 { pub fn to_mat(&self, viewport_size: Vec2) -> Mat4 {

View File

@ -12,7 +12,7 @@ use tracing::error;
use crate::DeltaTime; use crate::DeltaTime;
use super::{AtlasSprite, TextureAtlas}; use super::{AtlasSprite, Pivot, TextureAtlas};
/// A struct describing an animation of a Sprite. /// A struct describing an animation of a Sprite.
/// ///
@ -266,6 +266,7 @@ fn system_animation_entity_impl(
let sprite = AtlasSprite { let sprite = AtlasSprite {
atlas: animations.atlas.clone(), atlas: animations.atlas.clone(),
sprite: rect, sprite: rect,
pivot: Pivot::default(),
}; };
commands.insert(en, sprite); commands.insert(en, sprite);
@ -286,6 +287,7 @@ fn system_animation_entity_impl(
let new_sprite = AtlasSprite { let new_sprite = AtlasSprite {
atlas: animations.atlas.clone(), atlas: animations.atlas.clone(),
sprite: rect, sprite: rect,
pivot: Pivot::default(),
}; };
let sprite = sprite.as_mut().unwrap(); let sprite = sprite.as_mut().unwrap();

View File

@ -24,8 +24,9 @@ pub enum Pivot {
BottomLeft, BottomLeft,
BottomRight, BottomRight,
BottomCenter, BottomCenter,
/// A custom anchor point relative to top left. /// A custom anchor point.
/// Top left is `(0.0, 0.0)`. ///
/// Top left is (-0.5, 0.5), center is (0.0, 0.0).
Custom(Vec2) Custom(Vec2)
} }
@ -35,15 +36,15 @@ impl Pivot {
/// The point is offset from the top left `(0.0, 0.0)`. /// The point is offset from the top left `(0.0, 0.0)`.
pub fn as_vec(&self) -> Vec2 { pub fn as_vec(&self) -> Vec2 {
match self { match self {
Pivot::Center => Vec2::new(0.5, 0.5), Pivot::Center => Vec2::ZERO,
Pivot::CenterLeft => Vec2::new(0.0, 0.5), Pivot::CenterLeft => Vec2::new(-0.5, 0.0),
Pivot::CenterRight => Vec2::new(1.0, 0.5), Pivot::CenterRight => Vec2::new(0.5, 0.0),
Pivot::TopLeft => Vec2::ZERO, Pivot::TopLeft => Vec2::new(-0.5, 0.5),
Pivot::TopRight => Vec2::new(1.0, 0.0), Pivot::TopRight => Vec2::new(0.5, 0.5),
Pivot::TopCenter => Vec2::new(0.0, 0.5), Pivot::TopCenter => Vec2::new(0.0, 0.5),
Pivot::BottomLeft => Vec2::new(0.0, 1.0), Pivot::BottomLeft => Vec2::new(-0.5, -0.5),
Pivot::BottomRight => Vec2::new(1.0, 1.0), Pivot::BottomRight => Vec2::new(0.5, -0.5),
Pivot::BottomCenter => Vec2::new(0.5, 1.0), Pivot::BottomCenter => Vec2::new(0.0, -0.5),
Pivot::Custom(v) => *v, Pivot::Custom(v) => *v,
} }
} }

View File

@ -4,6 +4,8 @@ use lyra_math::URect;
use lyra_reflect::Reflect; use lyra_reflect::Reflect;
use lyra_resource::ResHandle; use lyra_resource::ResHandle;
use super::Pivot;
/// A texture atlas of multiple sprites. /// A texture atlas of multiple sprites.
#[derive(Clone, Component, Reflect)] #[derive(Clone, Component, Reflect)]
pub struct TextureAtlas { pub struct TextureAtlas {
@ -60,17 +62,19 @@ impl lyra_resource::ResourceData for TextureAtlas {
pub struct AtlasSprite { pub struct AtlasSprite {
pub atlas: ResHandle<TextureAtlas>, pub atlas: ResHandle<TextureAtlas>,
pub sprite: URect, pub sprite: URect,
pub pivot: Pivot,
} }
impl AtlasSprite { impl AtlasSprite {
#[inline(always)] #[inline(always)]
pub fn from_atlas_index(atlas: ResHandle<TextureAtlas>, i: u32) -> Self { pub fn from_atlas_index(atlas: ResHandle<TextureAtlas>, i: u32) -> Self {
let a = atlas.data_ref().unwrap(); let a = atlas.data_ref().unwrap();
let rect = a.frames.get(i as usize).cloned().unwrap(); //index_rect(i); let rect = a.frames.get(i as usize).cloned().unwrap();
Self { Self {
atlas: atlas.clone(), atlas: atlas.clone(),
sprite: rect, sprite: rect,
pivot: Pivot::default(),
} }
} }
} }

View File

@ -136,4 +136,26 @@ impl std::ops::Add for Transform {
self.scale *= rhs.scale; self.scale *= rhs.scale;
self self
} }
}
impl std::ops::Sub for Transform {
type Output = Transform;
fn sub(mut self, rhs: Self) -> Self::Output {
self.translation -= rhs.translation;
self.rotation *= rhs.rotation;
self.scale *= rhs.scale;
self
}
}
impl std::ops::Mul for Transform {
type Output = Transform;
fn mul(mut self, rhs: Self) -> Self::Output {
self.translation *= rhs.translation;
self.rotation *= rhs.rotation;
self.scale *= rhs.scale;
self
}
} }