feat(render): animate water with native EMBM waves
Docs Deploy / Build and Deploy MkDocs (push) Successful in 41s
Docs Deploy / Build and Deploy MkDocs (push) Successful in 41s
This commit is contained in:
15 files changed
+390
-42
No files matched your search
@@ -9,7 +9,7 @@ layout(location = 5) in vec3 in_base_diffuse;
|
||||
layout(location = 6) in vec3 in_base_specular;
|
||||
layout(location = 7) in vec3 in_overlay_diffuse;
|
||||
layout(location = 8) in vec3 in_overlay_specular;
|
||||
layout(location = 9) in float in_world_z;
|
||||
layout(location = 9) in vec3 in_world_position;
|
||||
layout(location = 0) out vec4 out_color;
|
||||
|
||||
layout(set = 0, binding = 0) uniform sampler2D base_color;
|
||||
@@ -17,6 +17,7 @@ layout(set = 0, binding = 1) uniform sampler2D base_detail;
|
||||
layout(set = 0, binding = 2) uniform sampler2D overlay_color;
|
||||
layout(set = 0, binding = 3) uniform sampler2D overlay_detail;
|
||||
layout(set = 1, binding = 0) uniform sampler2D reflection_color;
|
||||
layout(set = 1, binding = 1) uniform sampler2D embm_bump;
|
||||
|
||||
layout(std140, set = 0, binding = 4) uniform FrameConstants {
|
||||
mat4 clip_from_world;
|
||||
@@ -30,6 +31,8 @@ layout(std140, set = 0, binding = 4) uniform FrameConstants {
|
||||
vec4 fog_color;
|
||||
vec4 fog_distances;
|
||||
vec4 camera_position;
|
||||
vec4 embm_bounds;
|
||||
vec4 embm_wave;
|
||||
} frame;
|
||||
|
||||
layout(push_constant) uniform MaterialConstants {
|
||||
@@ -69,11 +72,23 @@ layout(push_constant) uniform MaterialConstants {
|
||||
|
||||
void main() {
|
||||
int mode = int(round(material.material_mode));
|
||||
if ((mode & 32) != 0 && (mode & 4) == 0 && in_world_z < frame.camera_position.w) {
|
||||
if ((mode & 32) != 0 && (mode & 4) == 0 && in_world_position.z < frame.camera_position.w) {
|
||||
discard;
|
||||
}
|
||||
if ((mode & 64) != 0) {
|
||||
vec2 reflection_uv = gl_FragCoord.xy / vec2(textureSize(reflection_color, 0));
|
||||
vec2 span = frame.embm_bounds.zw - frame.embm_bounds.xy;
|
||||
vec2 plane_uv = vec2(1.0) - (in_world_position.yx - frame.embm_bounds.yx) / span.yx;
|
||||
vec2 bump_uv = plane_uv * frame.embm_wave.y + frame.embm_wave.x;
|
||||
vec2 duv = max(vec2(-1.0), (texture(embm_bump, bump_uv).rg * 255.0 - 128.0) / 127.0);
|
||||
vec2 displacement = duv * frame.embm_wave.zw;
|
||||
// shortcut: convert native map-UV displacement to the current mirror projection;
|
||||
// replace this projection when accepting the original shifted reflection camera.
|
||||
vec3 displaced_world = in_world_position - vec3(displacement.yx * span, 0.0);
|
||||
vec4 displaced_clip = frame.clip_from_world * vec4(displaced_world, 1.0);
|
||||
if (displaced_clip.w > 0.000001) {
|
||||
reflection_uv = displaced_clip.xy / displaced_clip.w * 0.5 + 0.5;
|
||||
}
|
||||
vec3 rgb = texture(reflection_color, reflection_uv).rgb * in_base_diffuse + in_base_specular;
|
||||
out_color = vec4(mix(rgb * in_color, frame.fog_color.rgb, in_fog_factor), material.diffuse_alpha);
|
||||
if (out_color.a < material.alpha_cutoff) {
|
||||
|
||||
Binary file not shown.
@@ -19,7 +19,7 @@ layout(location = 5) out vec3 out_base_diffuse;
|
||||
layout(location = 6) out vec3 out_base_specular;
|
||||
layout(location = 7) out vec3 out_overlay_diffuse;
|
||||
layout(location = 8) out vec3 out_overlay_specular;
|
||||
layout(location = 9) out float out_world_z;
|
||||
layout(location = 9) out vec3 out_world_position;
|
||||
|
||||
layout(std140, set = 0, binding = 4) uniform FrameConstants {
|
||||
mat4 clip_from_world;
|
||||
@@ -33,6 +33,8 @@ layout(std140, set = 0, binding = 4) uniform FrameConstants {
|
||||
vec4 fog_color;
|
||||
vec4 fog_distances;
|
||||
vec4 camera_position;
|
||||
vec4 embm_bounds;
|
||||
vec4 embm_wave;
|
||||
} frame;
|
||||
|
||||
layout(std430, set = 0, binding = 5) readonly buffer MaterialSpecular {
|
||||
@@ -242,7 +244,7 @@ LightingTerms native_material_lighting(
|
||||
}
|
||||
|
||||
void main() {
|
||||
out_world_z = in_position.z;
|
||||
out_world_position = in_position;
|
||||
out_color = in_color;
|
||||
out_uv = in_uv;
|
||||
out_detail_uv = in_detail_uv;
|
||||
|
||||
Binary file not shown.
@@ -36,14 +36,42 @@ pub(super) fn mesh_reflection_planes(mesh: &super::VulkanStaticMesh) -> Vec<Opti
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn mesh_reflection_bounds(mesh: &super::VulkanStaticMesh) -> Option<[f32; 4]> {
|
||||
let mut bounds = [
|
||||
f32::INFINITY,
|
||||
f32::INFINITY,
|
||||
f32::NEG_INFINITY,
|
||||
f32::NEG_INFINITY,
|
||||
];
|
||||
for range in mesh
|
||||
.draw_ranges
|
||||
.iter()
|
||||
.filter(|range| range.terrain_reflection)
|
||||
{
|
||||
for &index in &mesh.indices
|
||||
[range.first_index as usize..range.first_index as usize + range.index_count as usize]
|
||||
{
|
||||
let position = mesh.vertices[index as usize].position;
|
||||
bounds[0] = bounds[0].min(position[0]);
|
||||
bounds[1] = bounds[1].min(position[1]);
|
||||
bounds[2] = bounds[2].max(position[0]);
|
||||
bounds[3] = bounds[3].max(position[1]);
|
||||
}
|
||||
}
|
||||
(bounds.iter().all(|v| v.is_finite()) && bounds[2] > bounds[0] && bounds[3] > bounds[1])
|
||||
.then_some(bounds)
|
||||
}
|
||||
|
||||
/// Owned before pipeline creation; a dummy set prevents attachment feedback in the mirror pass.
|
||||
pub(super) struct ReflectionResources {
|
||||
device: ash::Device,
|
||||
pub layout: vk::DescriptorSetLayout,
|
||||
pool: vk::DescriptorPool,
|
||||
sampler: vk::Sampler,
|
||||
bump_sampler: vk::Sampler,
|
||||
pub sets: [vk::DescriptorSet; 2],
|
||||
dummy: Option<VulkanAllocatedImage>,
|
||||
bump: Option<VulkanAllocatedImage>,
|
||||
pub color: Option<VulkanAllocatedImage>,
|
||||
depth: Option<VulkanDepthAttachment>,
|
||||
pub framebuffer: vk::Framebuffer,
|
||||
@@ -54,25 +82,31 @@ impl ReflectionResources {
|
||||
instance: &VulkanInstanceProbe,
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
command_pool: vk::CommandPool,
|
||||
enabled: bool,
|
||||
) -> Result<Self, VulkanSmokeRendererError> {
|
||||
let mut owned = Self {
|
||||
device: device.device().clone(),
|
||||
layout: vk::DescriptorSetLayout::null(),
|
||||
pool: vk::DescriptorPool::null(),
|
||||
sampler: vk::Sampler::null(),
|
||||
bump_sampler: vk::Sampler::null(),
|
||||
sets: [vk::DescriptorSet::null(); 2],
|
||||
dummy: None,
|
||||
bump: None,
|
||||
color: None,
|
||||
depth: None,
|
||||
framebuffer: vk::Framebuffer::null(),
|
||||
};
|
||||
let binding = vk::DescriptorSetLayoutBinding::default()
|
||||
.binding(0)
|
||||
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
|
||||
.descriptor_count(1)
|
||||
.stage_flags(vk::ShaderStageFlags::FRAGMENT);
|
||||
let layout_info =
|
||||
vk::DescriptorSetLayoutCreateInfo::default().bindings(std::slice::from_ref(&binding));
|
||||
let bindings: Vec<_> = (0..2)
|
||||
.map(|binding| {
|
||||
vk::DescriptorSetLayoutBinding::default()
|
||||
.binding(binding)
|
||||
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
|
||||
.descriptor_count(1)
|
||||
.stage_flags(vk::ShaderStageFlags::FRAGMENT)
|
||||
})
|
||||
.collect();
|
||||
let layout_info = vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings);
|
||||
// SAFETY: The device is live; owned rolls back every handle on later failure.
|
||||
owned.layout = unsafe {
|
||||
owned
|
||||
@@ -85,7 +119,7 @@ impl ReflectionResources {
|
||||
})?;
|
||||
let size = vk::DescriptorPoolSize::default()
|
||||
.ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
|
||||
.descriptor_count(2);
|
||||
.descriptor_count(4);
|
||||
let pool_info = vk::DescriptorPoolCreateInfo::default()
|
||||
.max_sets(2)
|
||||
.pool_sizes(std::slice::from_ref(&size));
|
||||
@@ -136,6 +170,42 @@ impl ReflectionResources {
|
||||
mip_levels: Vec::new(),
|
||||
},
|
||||
)?);
|
||||
if enabled {
|
||||
let sampler = sampler
|
||||
.address_mode_u(vk::SamplerAddressMode::REPEAT)
|
||||
.address_mode_v(vk::SamplerAddressMode::REPEAT)
|
||||
.address_mode_w(vk::SamplerAddressMode::REPEAT);
|
||||
// SAFETY: The immutable bump map uses supported linear filtering and a single mip level.
|
||||
owned.bump_sampler =
|
||||
unsafe { owned.device.create_sampler(&sampler, None) }.map_err(|result| {
|
||||
VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkCreateSampler(EMBM)",
|
||||
result,
|
||||
}
|
||||
})?;
|
||||
let rgba8 = fparkan_render::water::legacy_embm_bump_map()
|
||||
.into_iter()
|
||||
.flat_map(|duv| {
|
||||
[
|
||||
(i16::from(duv[0]) + 128) as u8,
|
||||
(i16::from(duv[1]) + 128) as u8,
|
||||
0,
|
||||
255,
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
owned.bump = Some(super::create_static_texture_image(
|
||||
instance,
|
||||
device,
|
||||
command_pool,
|
||||
&super::VulkanStaticTexture {
|
||||
width: fparkan_render::water::EMBM_BUMP_SIZE,
|
||||
height: fparkan_render::water::EMBM_BUMP_SIZE,
|
||||
rgba8,
|
||||
mip_levels: Vec::new(),
|
||||
},
|
||||
)?);
|
||||
}
|
||||
for set in owned.sets {
|
||||
owned.write_image(set, owned.dummy.as_ref().expect("dummy image exists").view);
|
||||
}
|
||||
@@ -255,13 +325,35 @@ impl ReflectionResources {
|
||||
.sampler(self.sampler)
|
||||
.image_view(view)
|
||||
.image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL);
|
||||
let write = vk::WriteDescriptorSet::default()
|
||||
.dst_set(set)
|
||||
.dst_binding(0)
|
||||
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
|
||||
.image_info(std::slice::from_ref(&image));
|
||||
let bump = self
|
||||
.bump
|
||||
.as_ref()
|
||||
.or(self.dummy.as_ref())
|
||||
.expect("dummy image exists");
|
||||
let images = [
|
||||
image,
|
||||
vk::DescriptorImageInfo::default()
|
||||
.sampler(if self.bump.is_some() {
|
||||
self.bump_sampler
|
||||
} else {
|
||||
self.sampler
|
||||
})
|
||||
.image_view(bump.view)
|
||||
.image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL),
|
||||
];
|
||||
let writes: Vec<_> = images
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(binding, image)| {
|
||||
vk::WriteDescriptorSet::default()
|
||||
.dst_set(set)
|
||||
.dst_binding(binding as u32)
|
||||
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
|
||||
.image_info(std::slice::from_ref(image))
|
||||
})
|
||||
.collect();
|
||||
// SAFETY: Sets are initialized before submission; the dummy remains valid for every pass.
|
||||
unsafe { self.device.update_descriptor_sets(&[write], &[]) };
|
||||
unsafe { self.device.update_descriptor_sets(&writes, &[]) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,12 +365,14 @@ impl Drop for ReflectionResources {
|
||||
self.device.destroy_framebuffer(self.framebuffer, None);
|
||||
self.device.destroy_descriptor_pool(self.pool, None);
|
||||
self.device.destroy_sampler(self.sampler, None);
|
||||
self.device.destroy_sampler(self.bump_sampler, None);
|
||||
self.device.destroy_descriptor_set_layout(self.layout, None);
|
||||
for image in self
|
||||
.color
|
||||
.iter()
|
||||
.chain(self.depth.iter().map(|depth| &depth.image))
|
||||
.chain(self.dummy.iter())
|
||||
.chain(self.bump.iter())
|
||||
{
|
||||
self.device.destroy_image_view(image.view, None);
|
||||
self.device.destroy_image(image.image, None);
|
||||
@@ -320,12 +414,19 @@ mod tests {
|
||||
alpha_test_reference: 1,
|
||||
}],
|
||||
};
|
||||
mesh.vertices[1].position = [10.0, 0.0, 150.0];
|
||||
mesh.vertices[2].position = [5.0, 20.0, 150.0];
|
||||
let mut unused = mesh.vertices[0];
|
||||
unused.position = [-10_000.0, -10_000.0, 150.0];
|
||||
mesh.vertices.push(unused);
|
||||
assert_eq!(mesh_reflection_bounds(&mesh), Some([0.0, 0.0, 10.0, 20.0]));
|
||||
assert_eq!(mesh_reflection_planes(&mesh), vec![Some(150.0)]);
|
||||
mesh.vertices[2].position[2] = 151.0;
|
||||
assert_eq!(mesh_reflection_planes(&mesh), vec![None]);
|
||||
mesh.vertices[2].position[2] = 150.0;
|
||||
mesh.draw_ranges[0].terrain_reflection = false;
|
||||
assert_eq!(mesh_reflection_planes(&mesh), vec![None]);
|
||||
assert_eq!(mesh_reflection_bounds(&mesh), None);
|
||||
for cull in [
|
||||
fparkan_render::LegacyCullMode::Disabled,
|
||||
fparkan_render::LegacyCullMode::BackFace,
|
||||
|
||||
@@ -373,12 +373,17 @@ impl VulkanSmokeRenderer {
|
||||
plane_heights.sort_unstable();
|
||||
plane_heights.dedup();
|
||||
if !plane_heights.is_empty() {
|
||||
eprintln!("render_coverage planar_reflection_embm_unsupported={} (flat reflection; native shifted camera and wave distortion pending)", plane_heights.len());
|
||||
eprintln!("render_coverage planar_reflection_shifted_camera_unsupported={} (native bump map and clock; using a projective mirror until shifted-camera mapping is implemented)", plane_heights.len());
|
||||
eprintln!("render_coverage planar_reflection_other_planes_unsupported={} (nearest active plane is reflected)", plane_heights.len().saturating_sub(1));
|
||||
}
|
||||
if unsupported_nonplanar != 0 {
|
||||
eprintln!("render_coverage planar_reflection_nonplanar_unsupported={unsupported_nonplanar} (ordinary terrain fallback)");
|
||||
}
|
||||
let mut frame_uniforms = super::VulkanFrameUniforms::from_camera(create_info.camera);
|
||||
if let Some(bounds) = super::reflection::mesh_reflection_bounds(&create_info.mesh) {
|
||||
frame_uniforms.embm_bounds = bounds;
|
||||
eprintln!("render_reflection embm_bounds={bounds:?}");
|
||||
}
|
||||
let mut renderer = Self {
|
||||
instance: Some(instance),
|
||||
validation,
|
||||
@@ -424,7 +429,7 @@ impl VulkanSmokeRenderer {
|
||||
material_specular_dirty: true,
|
||||
readback_enabled: false,
|
||||
camera: create_info.camera,
|
||||
frame_uniforms: super::VulkanFrameUniforms::from_camera(create_info.camera),
|
||||
frame_uniforms,
|
||||
point_lights: Vec::new(),
|
||||
point_lights_explicit: false,
|
||||
frame_sync: Vec::new(),
|
||||
@@ -1571,8 +1576,8 @@ impl VulkanSmokeRenderer {
|
||||
});
|
||||
}
|
||||
let mut bytes = frame.to_ne_bytes();
|
||||
let length = bytes.len();
|
||||
bytes[length - 4..].copy_from_slice(&height.to_ne_bytes());
|
||||
let offset = super::smoke_types::REFLECTION_CLIP_HEIGHT_OFFSET;
|
||||
bytes[offset..offset + 4].copy_from_slice(&height.to_ne_bytes());
|
||||
Ok(bytes)
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
@@ -192,7 +192,8 @@ impl Default for VulkanPointLight {
|
||||
/// every device. Four directional lights and one point light fit in a small
|
||||
/// dynamic UBO while the push-constant range remains available for material
|
||||
/// state.
|
||||
pub const VULKAN_FRAME_UNIFORM_BYTES: usize = 368;
|
||||
pub const VULKAN_FRAME_UNIFORM_BYTES: usize = 400;
|
||||
pub(super) const REFLECTION_CLIP_HEIGHT_OFFSET: usize = 364;
|
||||
|
||||
/// Number of CPU/GPU frame slots shared by the swapchain renderer.
|
||||
pub const VULKAN_FRAME_SLOT_COUNT: usize = 2;
|
||||
@@ -235,6 +236,10 @@ pub struct VulkanFrameUniforms {
|
||||
pub fog_end: f32,
|
||||
/// World-space camera position used for fog distance.
|
||||
pub camera_position: [f32; 3],
|
||||
/// XY bounds of the original reflective faces: min X/Y, max X/Y.
|
||||
pub embm_bounds: [f32; 4],
|
||||
/// Native wave scroll fraction in the ten-second cycle.
|
||||
pub embm_phase: f32,
|
||||
}
|
||||
|
||||
impl VulkanFrameUniforms {
|
||||
@@ -258,6 +263,8 @@ impl VulkanFrameUniforms {
|
||||
fog_start: f32::MAX,
|
||||
fog_end: f32::MAX,
|
||||
camera_position: [0.0; 3],
|
||||
embm_bounds: [0.0, 0.0, 1.0, 1.0],
|
||||
embm_phase: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,7 +290,14 @@ impl VulkanFrameUniforms {
|
||||
.chain(self.fog_color)
|
||||
.chain([self.fog_start, self.fog_end])
|
||||
.chain(self.camera_position)
|
||||
.chain(self.embm_bounds)
|
||||
.chain([self.embm_phase])
|
||||
.all(f32::is_finite)
|
||||
&& self.embm_bounds[2] > self.embm_bounds[0]
|
||||
&& self.embm_bounds[3] > self.embm_bounds[1]
|
||||
&& (self.embm_bounds[2] - self.embm_bounds[0]).is_finite()
|
||||
&& (self.embm_bounds[3] - self.embm_bounds[1]).is_finite()
|
||||
&& (0.0..1.0).contains(&self.embm_phase)
|
||||
&& (self.fog_end <= self.fog_start
|
||||
|| ((self.fog_start * self.fog_start).is_finite()
|
||||
&& (self.fog_end * self.fog_end).is_finite()))
|
||||
@@ -364,6 +378,13 @@ impl VulkanFrameUniforms {
|
||||
self.camera_position[2],
|
||||
0.0,
|
||||
]);
|
||||
values[92..96].copy_from_slice(&self.embm_bounds);
|
||||
values[96..100].copy_from_slice(&[
|
||||
self.embm_phase,
|
||||
fparkan_render::water::EMBM_BUMP_TILE,
|
||||
fparkan_render::water::EMBM_COEFFICIENTS[0],
|
||||
fparkan_render::water::EMBM_COEFFICIENTS[1],
|
||||
]);
|
||||
let mut bytes = [0_u8; VULKAN_FRAME_UNIFORM_BYTES];
|
||||
for (index, value) in values.into_iter().enumerate() {
|
||||
let offset = index * std::mem::size_of::<f32>();
|
||||
@@ -862,6 +883,8 @@ mod static_mesh_tests {
|
||||
fog_start: 13.0,
|
||||
fog_end: 14.0,
|
||||
camera_position: [15.0, 16.0, 17.0],
|
||||
embm_bounds: [100.0, 200.0, 300.0, 600.0],
|
||||
embm_phase: 0.25,
|
||||
};
|
||||
let bytes = frame.to_ne_bytes();
|
||||
assert_eq!(bytes.len(), VULKAN_FRAME_UNIFORM_BYTES);
|
||||
@@ -880,6 +903,28 @@ mod static_mesh_tests {
|
||||
assert_eq!(read(320), 10.0);
|
||||
assert_eq!(read(336), 13.0);
|
||||
assert_eq!(read(352), 15.0);
|
||||
assert_eq!(read(368), 100.0);
|
||||
assert_eq!(read(380), 600.0);
|
||||
assert_eq!(read(384), 0.25);
|
||||
assert_eq!(read(388), 100.0);
|
||||
assert_eq!(read(392), 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embm_uniforms_reject_invalid_bounds_and_phase() {
|
||||
for (bounds, phase) in [
|
||||
([0.0; 4], 0.0),
|
||||
([-f32::MAX, 0.0, f32::MAX, 1.0], 0.0),
|
||||
([0.0, 0.0, 1.0, 1.0], f32::NAN),
|
||||
([0.0, 0.0, 1.0, 1.0], 1.0),
|
||||
] {
|
||||
assert!(!VulkanFrameUniforms {
|
||||
embm_bounds: bounds,
|
||||
embm_phase: phase,
|
||||
..Default::default()
|
||||
}
|
||||
.is_finite());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -249,14 +249,18 @@ pub(super) fn create_swapchain_resources(
|
||||
};
|
||||
partial.frame_uniform_stride = frame_uniform_stride;
|
||||
partial.frame_uniform_buffer = Some(frame_uniform_buffer);
|
||||
partial.reflection =
|
||||
match super::reflection::ReflectionResources::new(instance, device, command_pool) {
|
||||
Ok(reflection) => Some(reflection),
|
||||
Err(error) => {
|
||||
destroy_partial_swapchain_resources(device, command_pool, partial);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
partial.reflection = match super::reflection::ReflectionResources::new(
|
||||
instance,
|
||||
device,
|
||||
command_pool,
|
||||
reflection_enabled,
|
||||
) {
|
||||
Ok(reflection) => Some(reflection),
|
||||
Err(error) => {
|
||||
destroy_partial_swapchain_resources(device, command_pool, partial);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let material_count = texture_slot_indices.len() / 4;
|
||||
let (material_specular_buffer, material_specular_stride) =
|
||||
match create_material_specular_buffer(instance, device, material_count) {
|
||||
|
||||
Reference in new issue
Block a user