Add port of AMD's Robust Contrast Adaptive Sharpening (#7422)

# Objective

TAA, FXAA, and some other post processing effects can cause the image to
become blurry. Sharpening helps to counteract that.

## Solution

~~This is a port of AMD's Contrast Adaptive Sharpening (I ported it from
the
[SweetFX](https://github.com/CeeJayDK/SweetFX/blob/master/Shaders/CAS.fx)
version, which is still MIT licensed). CAS is a good sharpening
algorithm that is better at avoiding the full screen oversharpening
artifacts that simpler algorithms tend to create.~~

This is a port of AMD's Robust Contrast Adaptive Sharpening (RCAS) which
they developed for FSR 1 ([and continue to use in FSR
2](149cf26e12/src/ffx-fsr2-api/shaders/ffx_fsr1.h (L599))).
RCAS is a good sharpening algorithm that is better at avoiding the full
screen oversharpening artifacts that simpler algorithms tend to create.

---

## Future Work

- Consider porting this to a compute shader for potentially better
performance. (In my testing it is currently ridiculously cheap (0.01ms
in Bistro at 1440p where I'm GPU bound), so this wasn't a priority,
especially since it would increase complexity due to still needing the
non-compute version for webgl2 support).

---

## Changelog

- Added Contrast Adaptive Sharpening.

---------

Co-authored-by: JMS55 <47158642+JMS55@users.noreply.github.com>
This commit is contained in:
Elabajaba 2023-04-02 16:14:01 -04:00 committed by GitHub
parent f0f5d79917
commit 09f1bd0be7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 567 additions and 10 deletions

View File

@ -0,0 +1,275 @@
use crate::{core_2d, core_3d, fullscreen_vertex_shader::fullscreen_shader_vertex_state};
use bevy_app::prelude::*;
use bevy_asset::{load_internal_asset, HandleUntyped};
use bevy_ecs::{prelude::*, query::QueryItem};
use bevy_reflect::{Reflect, TypeUuid};
use bevy_render::{
extract_component::{ExtractComponent, ExtractComponentPlugin, UniformComponentPlugin},
prelude::Camera,
render_graph::RenderGraph,
render_resource::*,
renderer::RenderDevice,
texture::BevyDefault,
view::{ExtractedView, ViewTarget},
Render, RenderApp, RenderSet,
};
mod node;
pub use node::CASNode;
/// Applies a contrast adaptive sharpening (CAS) filter to the camera.
///
/// CAS is usually used in combination with shader based anti-aliasing methods
/// such as FXAA or TAA to regain some of the lost detail from the blurring that they introduce.
///
/// CAS is designed to adjust the amount of sharpening applied to different areas of an image
/// based on the local contrast. This can help avoid over-sharpening areas with high contrast
/// and under-sharpening areas with low contrast.
///
/// To use this, add the [`ContrastAdaptiveSharpeningSettings`] component to a 2D or 3D camera.
#[derive(Component, Reflect, Clone)]
#[reflect(Component)]
pub struct ContrastAdaptiveSharpeningSettings {
/// Enable or disable sharpening.
pub enabled: bool,
/// Adjusts sharpening strength. Higher values increase the amount of sharpening.
///
/// Clamped between 0.0 and 1.0.
///
/// The default value is 0.6.
pub sharpening_strength: f32,
/// Whether to try and avoid sharpening areas that are already noisy.
///
/// You probably shouldn't use this, and just leave it set to false.
/// You should generally apply any sort of film grain or similar effects after CAS
/// and upscaling to avoid artifacts.
pub denoise: bool,
}
impl Default for ContrastAdaptiveSharpeningSettings {
fn default() -> Self {
ContrastAdaptiveSharpeningSettings {
enabled: true,
sharpening_strength: 0.6,
denoise: false,
}
}
}
#[derive(Component, Default, Reflect, Clone)]
#[reflect(Component)]
pub struct DenoiseCAS(bool);
/// The uniform struct extracted from [`ContrastAdaptiveSharpeningSettings`] attached to a [`Camera`].
/// Will be available for use in the CAS shader.
#[doc(hidden)]
#[derive(Component, ShaderType, Clone)]
pub struct CASUniform {
sharpness: f32,
}
impl ExtractComponent for ContrastAdaptiveSharpeningSettings {
type Query = &'static Self;
type Filter = With<Camera>;
type Out = (DenoiseCAS, CASUniform);
fn extract_component(item: QueryItem<Self::Query>) -> Option<Self::Out> {
if !item.enabled || item.sharpening_strength == 0.0 {
return None;
}
Some((
DenoiseCAS(item.denoise),
CASUniform {
// above 1.0 causes extreme artifacts and fireflies
sharpness: item.sharpening_strength.clamp(0.0, 1.0),
},
))
}
}
const CONTRAST_ADAPTIVE_SHARPENING_SHADER_HANDLE: HandleUntyped =
HandleUntyped::weak_from_u64(Shader::TYPE_UUID, 6925381244141981602);
/// Adds Support for Contrast Adaptive Sharpening (CAS).
pub struct CASPlugin;
impl Plugin for CASPlugin {
fn build(&self, app: &mut App) {
load_internal_asset!(
app,
CONTRAST_ADAPTIVE_SHARPENING_SHADER_HANDLE,
"robust_contrast_adaptive_sharpening.wgsl",
Shader::from_wgsl
);
app.register_type::<ContrastAdaptiveSharpeningSettings>();
app.add_plugin(ExtractComponentPlugin::<ContrastAdaptiveSharpeningSettings>::default());
app.add_plugin(UniformComponentPlugin::<CASUniform>::default());
let render_app = match app.get_sub_app_mut(RenderApp) {
Ok(render_app) => render_app,
Err(_) => return,
};
render_app
.init_resource::<CASPipeline>()
.init_resource::<SpecializedRenderPipelines<CASPipeline>>()
.add_systems(Render, prepare_cas_pipelines.in_set(RenderSet::Prepare));
{
let cas_node = CASNode::new(&mut render_app.world);
let mut binding = render_app.world.resource_mut::<RenderGraph>();
let graph = binding.get_sub_graph_mut(core_3d::graph::NAME).unwrap();
graph.add_node(core_3d::graph::node::CONTRAST_ADAPTIVE_SHARPENING, cas_node);
graph.add_node_edge(
core_3d::graph::node::TONEMAPPING,
core_3d::graph::node::CONTRAST_ADAPTIVE_SHARPENING,
);
graph.add_node_edge(
core_3d::graph::node::FXAA,
core_3d::graph::node::CONTRAST_ADAPTIVE_SHARPENING,
);
graph.add_node_edge(
core_3d::graph::node::CONTRAST_ADAPTIVE_SHARPENING,
core_3d::graph::node::END_MAIN_PASS_POST_PROCESSING,
);
}
{
let cas_node = CASNode::new(&mut render_app.world);
let mut binding = render_app.world.resource_mut::<RenderGraph>();
let graph = binding.get_sub_graph_mut(core_2d::graph::NAME).unwrap();
graph.add_node(core_2d::graph::node::CONTRAST_ADAPTIVE_SHARPENING, cas_node);
graph.add_node_edge(
core_2d::graph::node::TONEMAPPING,
core_2d::graph::node::CONTRAST_ADAPTIVE_SHARPENING,
);
graph.add_node_edge(
core_2d::graph::node::FXAA,
core_2d::graph::node::CONTRAST_ADAPTIVE_SHARPENING,
);
graph.add_node_edge(
core_2d::graph::node::CONTRAST_ADAPTIVE_SHARPENING,
core_2d::graph::node::END_MAIN_PASS_POST_PROCESSING,
);
}
}
}
#[derive(Resource)]
pub struct CASPipeline {
texture_bind_group: BindGroupLayout,
sampler: Sampler,
}
impl FromWorld for CASPipeline {
fn from_world(render_world: &mut World) -> Self {
let render_device = render_world.resource::<RenderDevice>();
let texture_bind_group =
render_device.create_bind_group_layout(&BindGroupLayoutDescriptor {
label: Some("sharpening_texture_bind_group_layout"),
entries: &[
BindGroupLayoutEntry {
binding: 0,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Texture {
sample_type: TextureSampleType::Float { filterable: true },
view_dimension: TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
BindGroupLayoutEntry {
binding: 1,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Sampler(SamplerBindingType::Filtering),
count: None,
},
// CAS Settings
BindGroupLayoutEntry {
binding: 2,
ty: BindingType::Buffer {
ty: BufferBindingType::Uniform,
has_dynamic_offset: true,
min_binding_size: Some(CASUniform::min_size()),
},
visibility: ShaderStages::FRAGMENT,
count: None,
},
],
});
let sampler = render_device.create_sampler(&SamplerDescriptor::default());
CASPipeline {
texture_bind_group,
sampler,
}
}
}
#[derive(PartialEq, Eq, Hash, Clone, Copy)]
pub struct CASPipelineKey {
texture_format: TextureFormat,
denoise: bool,
}
impl SpecializedRenderPipeline for CASPipeline {
type Key = CASPipelineKey;
fn specialize(&self, key: Self::Key) -> RenderPipelineDescriptor {
let mut shader_defs = vec![];
if key.denoise {
shader_defs.push("RCAS_DENOISE".into());
}
RenderPipelineDescriptor {
label: Some("contrast_adaptive_sharpening".into()),
layout: vec![self.texture_bind_group.clone()],
vertex: fullscreen_shader_vertex_state(),
fragment: Some(FragmentState {
shader: CONTRAST_ADAPTIVE_SHARPENING_SHADER_HANDLE.typed(),
shader_defs,
entry_point: "fragment".into(),
targets: vec![Some(ColorTargetState {
format: key.texture_format,
blend: None,
write_mask: ColorWrites::ALL,
})],
}),
primitive: PrimitiveState::default(),
depth_stencil: None,
multisample: MultisampleState::default(),
push_constant_ranges: Vec::new(),
}
}
}
fn prepare_cas_pipelines(
mut commands: Commands,
pipeline_cache: Res<PipelineCache>,
mut pipelines: ResMut<SpecializedRenderPipelines<CASPipeline>>,
sharpening_pipeline: Res<CASPipeline>,
views: Query<(Entity, &ExtractedView, &DenoiseCAS), With<CASUniform>>,
) {
for (entity, view, cas_settings) in &views {
let pipeline_id = pipelines.specialize(
&pipeline_cache,
&sharpening_pipeline,
CASPipelineKey {
denoise: cas_settings.0,
texture_format: if view.hdr {
ViewTarget::TEXTURE_FORMAT_HDR
} else {
TextureFormat::bevy_default()
},
},
);
commands.entity(entity).insert(ViewCASPipeline(pipeline_id));
}
}
#[derive(Component)]
pub struct ViewCASPipeline(CachedRenderPipelineId);

View File

@ -0,0 +1,125 @@
use std::sync::Mutex;
use crate::contrast_adaptive_sharpening::ViewCASPipeline;
use bevy_ecs::prelude::*;
use bevy_ecs::query::QueryState;
use bevy_render::{
extract_component::{ComponentUniforms, DynamicUniformIndex},
render_graph::{Node, NodeRunError, RenderGraphContext},
render_resource::{
BindGroup, BindGroupDescriptor, BindGroupEntry, BindingResource, BufferId, Operations,
PipelineCache, RenderPassColorAttachment, RenderPassDescriptor, TextureViewId,
},
renderer::RenderContext,
view::{ExtractedView, ViewTarget},
};
use super::{CASPipeline, CASUniform};
pub struct CASNode {
query: QueryState<
(
&'static ViewTarget,
&'static ViewCASPipeline,
&'static DynamicUniformIndex<CASUniform>,
),
With<ExtractedView>,
>,
cached_bind_group: Mutex<Option<(BufferId, TextureViewId, BindGroup)>>,
}
impl CASNode {
pub fn new(world: &mut World) -> Self {
Self {
query: QueryState::new(world),
cached_bind_group: Mutex::new(None),
}
}
}
impl Node for CASNode {
fn update(&mut self, world: &mut World) {
self.query.update_archetypes(world);
}
fn run(
&self,
graph: &mut RenderGraphContext,
render_context: &mut RenderContext,
world: &World,
) -> Result<(), NodeRunError> {
let view_entity = graph.view_entity();
let pipeline_cache = world.resource::<PipelineCache>();
let sharpening_pipeline = world.resource::<CASPipeline>();
let uniforms = world.resource::<ComponentUniforms<CASUniform>>();
let Ok((target, pipeline, uniform_index)) = self.query.get_manual(world, view_entity) else { return Ok(()) };
let uniforms_id = uniforms.buffer().unwrap().id();
let Some(uniforms) = uniforms.binding() else { return Ok(()) };
let pipeline = pipeline_cache.get_render_pipeline(pipeline.0).unwrap();
let view_target = target.post_process_write();
let source = view_target.source;
let destination = view_target.destination;
let mut cached_bind_group = self.cached_bind_group.lock().unwrap();
let bind_group = match &mut *cached_bind_group {
Some((buffer_id, texture_id, bind_group))
if source.id() == *texture_id && uniforms_id == *buffer_id =>
{
bind_group
}
cached_bind_group => {
let bind_group =
render_context
.render_device()
.create_bind_group(&BindGroupDescriptor {
label: Some("cas_bind_group"),
layout: &sharpening_pipeline.texture_bind_group,
entries: &[
BindGroupEntry {
binding: 0,
resource: BindingResource::TextureView(view_target.source),
},
BindGroupEntry {
binding: 1,
resource: BindingResource::Sampler(
&sharpening_pipeline.sampler,
),
},
BindGroupEntry {
binding: 2,
resource: uniforms,
},
],
});
let (_, _, bind_group) =
cached_bind_group.insert((uniforms_id, source.id(), bind_group));
bind_group
}
};
let pass_descriptor = RenderPassDescriptor {
label: Some("contrast_adaptive_sharpening"),
color_attachments: &[Some(RenderPassColorAttachment {
view: destination,
resolve_target: None,
ops: Operations::default(),
})],
depth_stencil_attachment: None,
};
let mut render_pass = render_context
.command_encoder()
.begin_render_pass(&pass_descriptor);
render_pass.set_pipeline(pipeline);
render_pass.set_bind_group(0, bind_group, &[uniform_index.index()]);
render_pass.draw(0..3, 0..1);
Ok(())
}
}

View File

@ -0,0 +1,98 @@
// Copyright (c) 2022 Advanced Micro Devices, Inc. All rights reserved.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#import bevy_core_pipeline::fullscreen_vertex_shader
struct CASUniforms {
sharpness: f32,
};
@group(0) @binding(0)
var screenTexture: texture_2d<f32>;
@group(0) @binding(1)
var samp: sampler;
@group(0) @binding(2)
var<uniform> uniforms: CASUniforms;
// This is set at the limit of providing unnatural results for sharpening.
const FSR_RCAS_LIMIT = 0.1875;
// -4.0 instead of -1.0 to avoid issues with MSAA.
const peakC = vec2<f32>(10.0, -40.0);
// Robust Contrast Adaptive Sharpening (RCAS)
// Based on the following implementation:
// https://github.com/GPUOpen-Effects/FidelityFX-FSR2/blob/ea97a113b0f9cadf519fbcff315cc539915a3acd/src/ffx-fsr2-api/shaders/ffx_fsr1.h#L672
// RCAS is based on the following logic.
// RCAS uses a 5 tap filter in a cross pattern (same as CAS),
// W b
// W 1 W for taps d e f
// W h
// Where 'W' is the negative lobe weight.
// output = (W*(b+d+f+h)+e)/(4*W+1)
// RCAS solves for 'W' by seeing where the signal might clip out of the {0 to 1} input range,
// 0 == (W*(b+d+f+h)+e)/(4*W+1) -> W = -e/(b+d+f+h)
// 1 == (W*(b+d+f+h)+e)/(4*W+1) -> W = (1-e)/(b+d+f+h-4)
// Then chooses the 'W' which results in no clipping, limits 'W', and multiplies by the 'sharp' amount.
// This solution above has issues with MSAA input as the steps along the gradient cause edge detection issues.
// So RCAS uses 4x the maximum and 4x the minimum (depending on equation)in place of the individual taps.
// As well as switching from 'e' to either the minimum or maximum (depending on side), to help in energy conservation.
// This stabilizes RCAS.
// RCAS does a simple highpass which is normalized against the local contrast then shaped,
// 0.25
// 0.25 -1 0.25
// 0.25
// This is used as a noise detection filter, to reduce the effect of RCAS on grain, and focus on real edges.
// The CAS node runs after tonemapping, so the input will be in the range of 0 to 1.
@fragment
fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
// Algorithm uses minimal 3x3 pixel neighborhood.
// b
// d e f
// h
let b = textureSample(screenTexture, samp, in.uv, vec2<i32>(0, -1)).rgb;
let d = textureSample(screenTexture, samp, in.uv, vec2<i32>(-1, 0)).rgb;
// We need the alpha value of the pixel we're working on for the output
let e = textureSample(screenTexture, samp, in.uv).rgbw;
let f = textureSample(screenTexture, samp, in.uv, vec2<i32>(1, 0)).rgb;
let h = textureSample(screenTexture, samp, in.uv, vec2<i32>(0, 1)).rgb;
// Min and max of ring.
let mn4 = min(min(b, d), min(f, h));
let mx4 = max(max(b, d), max(f, h));
// Limiters
// 4.0 to avoid issues with MSAA.
let hitMin = mn4 / (4.0 * mx4);
let hitMax = (peakC.x - mx4) / (peakC.y + 4.0 * mn4);
let lobeRGB = max(-hitMin, hitMax);
var lobe = max(-FSR_RCAS_LIMIT, min(0.0, max(lobeRGB.r, max(lobeRGB.g, lobeRGB.b)))) * uniforms.sharpness;
#ifdef RCAS_DENOISE
// Luma times 2.
let bL = b.b * 0.5 + (b.r * 0.5 + b.g);
let dL = d.b * 0.5 + (d.r * 0.5 + d.g);
let eL = e.b * 0.5 + (e.r * 0.5 + e.g);
let fL = f.b * 0.5 + (f.r * 0.5 + f.g);
let hL = h.b * 0.5 + (h.r * 0.5 + h.g);
// Noise detection.
var noise = 0.25 * bL + 0.25 * dL + 0.25 * fL + 0.25 * hL - eL;;
noise = saturate(abs(noise) / (max(max(bL, dL), max(fL, hL)) - min(min(bL, dL), min(fL, hL))));
noise = 1.0 - 0.5 * noise;
// Apply noise removal.
lobe *= noise;
#endif
return vec4<f32>((lobe * b + lobe * d + lobe * f + lobe * h + e.rgb) / (4.0 * lobe + 1.0), e.w);
}

View File

@ -13,6 +13,7 @@ pub mod graph {
pub const TONEMAPPING: &str = "tonemapping";
pub const FXAA: &str = "fxaa";
pub const UPSCALING: &str = "upscaling";
pub const CONTRAST_ADAPTIVE_SHARPENING: &str = "contrast_adaptive_sharpening";
pub const END_MAIN_PASS_POST_PROCESSING: &str = "end_main_pass_post_processing";
}
}

View File

@ -18,6 +18,7 @@ pub mod graph {
pub const TONEMAPPING: &str = "tonemapping";
pub const FXAA: &str = "fxaa";
pub const UPSCALING: &str = "upscaling";
pub const CONTRAST_ADAPTIVE_SHARPENING: &str = "contrast_adaptive_sharpening";
pub const END_MAIN_PASS_POST_PROCESSING: &str = "end_main_pass_post_processing";
}
}

View File

@ -1,6 +1,7 @@
pub mod blit;
pub mod bloom;
pub mod clear_color;
pub mod contrast_adaptive_sharpening;
pub mod core_2d;
pub mod core_3d;
pub mod fullscreen_vertex_shader;
@ -34,6 +35,7 @@ use crate::{
blit::BlitPlugin,
bloom::BloomPlugin,
clear_color::{ClearColor, ClearColorConfig},
contrast_adaptive_sharpening::CASPlugin,
core_2d::Core2dPlugin,
core_3d::Core3dPlugin,
fullscreen_vertex_shader::FULLSCREEN_SHADER_HANDLE,
@ -72,6 +74,7 @@ impl Plugin for CorePipelinePlugin {
.add_plugin(TonemappingPlugin)
.add_plugin(UpscalingPlugin)
.add_plugin(BloomPlugin)
.add_plugin(FxaaPlugin);
.add_plugin(FxaaPlugin)
.add_plugin(CASPlugin);
}
}

View File

@ -4,6 +4,7 @@ use std::f32::consts::PI;
use bevy::{
core_pipeline::{
contrast_adaptive_sharpening::ContrastAdaptiveSharpeningSettings,
experimental::taa::{
TemporalAntiAliasBundle, TemporalAntiAliasPlugin, TemporalAntiAliasSettings,
},
@ -23,7 +24,7 @@ fn main() {
.add_plugins(DefaultPlugins)
.add_plugin(TemporalAntiAliasPlugin)
.add_systems(Startup, setup)
.add_systems(Update, (modify_aa, update_ui))
.add_systems(Update, (modify_aa, modify_sharpening, update_ui))
.run();
}
@ -112,12 +113,43 @@ fn modify_aa(
}
}
fn modify_sharpening(
keys: Res<Input<KeyCode>>,
mut query: Query<&mut ContrastAdaptiveSharpeningSettings>,
) {
for mut cas in &mut query {
if keys.just_pressed(KeyCode::Key0) {
cas.enabled = !cas.enabled;
}
if cas.enabled {
if keys.just_pressed(KeyCode::Minus) {
cas.sharpening_strength -= 0.1;
cas.sharpening_strength = cas.sharpening_strength.clamp(0.0, 1.0);
}
if keys.just_pressed(KeyCode::Equals) {
cas.sharpening_strength += 0.1;
cas.sharpening_strength = cas.sharpening_strength.clamp(0.0, 1.0);
}
if keys.just_pressed(KeyCode::D) {
cas.denoise = !cas.denoise;
}
}
}
}
fn update_ui(
camera: Query<(Option<&Fxaa>, Option<&TemporalAntiAliasSettings>), With<Camera>>,
camera: Query<
(
Option<&Fxaa>,
Option<&TemporalAntiAliasSettings>,
&ContrastAdaptiveSharpeningSettings,
),
With<Camera>,
>,
msaa: Res<Msaa>,
mut ui: Query<&mut Text>,
) {
let (fxaa, taa) = camera.single();
let (fxaa, taa, cas_settings) = camera.single();
let mut ui = ui.single_mut();
let ui = &mut ui.sections[0].value;
@ -201,6 +233,21 @@ fn update_ui(
ui.push_str("(T) Extreme");
}
}
if cas_settings.enabled {
ui.push_str("\n\n----------\n\n(0) Sharpening (Enabled)\n");
ui.push_str(&format!(
"(-/+) Strength: {:.1}\n",
cas_settings.sharpening_strength
));
if cas_settings.denoise {
ui.push_str("(D) Denoising (Enabled)\n");
} else {
ui.push_str("(D) Denoising (Disabled)\n");
}
} else {
ui.push_str("\n\n----------\n\n(0) Sharpening (Disabled)\n");
}
}
/// Set up a simple 3D scene
@ -261,14 +308,21 @@ fn setup(
});
// Camera
commands.spawn(Camera3dBundle {
camera: Camera {
hdr: true,
commands.spawn((
Camera3dBundle {
camera: Camera {
hdr: true,
..default()
},
transform: Transform::from_xyz(0.7, 0.7, 1.0)
.looking_at(Vec3::new(0.0, 0.3, 0.0), Vec3::Y),
..default()
},
transform: Transform::from_xyz(0.7, 0.7, 1.0).looking_at(Vec3::new(0.0, 0.3, 0.0), Vec3::Y),
..default()
});
ContrastAdaptiveSharpeningSettings {
enabled: false,
..default()
},
));
// UI
commands.spawn(