summaryrefslogtreecommitdiff
path: root/client/src/render
diff options
context:
space:
mode:
authormetamuffin <metamuffin@disroot.org>2025-01-26 15:10:37 +0100
committermetamuffin <metamuffin@disroot.org>2025-01-26 15:10:37 +0100
commit66930534a0647e2613360658a6a99eed945e2f0f (patch)
tree31a769910ef924a11206f1024b4004f74b1e396f /client/src/render
parent0163f8486ceca8bd6897c1074f6846f36827d040 (diff)
downloadweareserver-66930534a0647e2613360658a6a99eed945e2f0f.tar
weareserver-66930534a0647e2613360658a6a99eed945e2f0f.tar.bz2
weareserver-66930534a0647e2613360658a6a99eed945e2f0f.tar.zst
move files around, graphics config, msaa
Diffstat (limited to 'client/src/render')
-rw-r--r--client/src/render/mod.rs130
-rw-r--r--client/src/render/scene/draw.rs5
-rw-r--r--client/src/render/scene/mod.rs16
-rw-r--r--client/src/render/scene/pipelines.rs12
-rw-r--r--client/src/render/scene/textures.rs2
-rw-r--r--client/src/render/shaders/fragment_pbr.wgsl64
-rw-r--r--client/src/render/shaders/fragment_ui.wgsl28
-rw-r--r--client/src/render/shaders/mod.rs37
-rw-r--r--client/src/render/shaders/texture_copy.wgsl37
-rw-r--r--client/src/render/shaders/vertex_ui.wgsl43
-rw-r--r--client/src/render/shaders/vertex_world.wgsl48
-rw-r--r--client/src/render/shaders/vertex_world_skin.wgsl58
-rw-r--r--client/src/render/ui.rs535
13 files changed, 964 insertions, 51 deletions
diff --git a/client/src/render/mod.rs b/client/src/render/mod.rs
index 6964337..db961f4 100644
--- a/client/src/render/mod.rs
+++ b/client/src/render/mod.rs
@@ -15,10 +15,11 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
pub mod scene;
+pub mod shaders;
+pub mod ui;
use crate::{
camera::Camera, download::Downloader, interfaces::profiler::TimingProfiler, state::InputState,
- ui::UiRenderer,
};
use anyhow::{Result, anyhow};
use log::{info, warn};
@@ -30,6 +31,7 @@ use std::{
thread::{sleep, spawn},
time::Duration,
};
+use ui::UiRenderer;
use weareshared::tree::SceneTree;
use wgpu::{
AdapterInfo, Backends, CommandEncoderDescriptor, Device, DeviceDescriptor, Extent3d, Features,
@@ -52,7 +54,18 @@ pub struct Renderer<'a> {
pub timing: TimingProfiler,
pub timing_submit: Arc<Mutex<TimingProfiler>>,
pub adapter_info: Arc<AdapterInfo>,
+
+ color_msaa: TextureView,
+ config: GraphicsConfig,
+}
+
+#[derive(Debug, Clone)]
+pub struct GraphicsConfig {
+ max_anisotropy: u16,
+ max_mip_count: u32,
+ sample_count: u32,
}
+
impl<'a> Renderer<'a> {
pub fn new(window: &'a Window, downloader: Arc<Downloader>) -> Result<Self> {
info!("wgpu init");
@@ -95,31 +108,60 @@ impl<'a> Renderer<'a> {
let device = Arc::new(device);
let queue = Arc::new(queue);
+ let config = GraphicsConfig {
+ max_anisotropy: 16,
+ max_mip_count: 16,
+ sample_count: 4,
+ };
+
+ let depth = device
+ .create_texture(&TextureDescriptor {
+ label: None,
+ size: Extent3d {
+ height: 256,
+ width: 256,
+ depth_or_array_layers: 1,
+ },
+ mip_level_count: 1,
+ sample_count: config.sample_count,
+ dimension: TextureDimension::D2,
+ format: TextureFormat::Depth32Float,
+ usage: TextureUsages::RENDER_ATTACHMENT,
+ view_formats: &[],
+ })
+ .create_view(&TextureViewDescriptor::default());
+
+ let color_msaa = device
+ .create_texture(&TextureDescriptor {
+ label: None,
+ size: Extent3d {
+ height: 256,
+ width: 256,
+ depth_or_array_layers: 1,
+ },
+ mip_level_count: 1,
+ sample_count: config.sample_count,
+ dimension: TextureDimension::D2,
+ format: surface_configuration.format,
+ usage: TextureUsages::RENDER_ATTACHMENT,
+ view_formats: &[],
+ })
+ .create_view(&TextureViewDescriptor::default());
+
let scene_prepare = Arc::new(ScenePreparer::new(
device.clone(),
queue.clone(),
surface_configuration.format,
downloader,
+ config.clone(),
));
- let ui_renderer =
- UiRenderer::new(device.clone(), queue.clone(), surface_configuration.format);
-
- let depth = device.create_texture(&TextureDescriptor {
- label: None,
- size: Extent3d {
- height: 256,
- width: 256,
- depth_or_array_layers: 1,
- },
- mip_level_count: 1,
- sample_count: 1,
- dimension: TextureDimension::D2,
- format: TextureFormat::Depth32Float,
- usage: TextureUsages::RENDER_ATTACHMENT,
- view_formats: &[],
- });
- let depth = depth.create_view(&TextureViewDescriptor::default());
+ let ui_renderer = UiRenderer::new(
+ device.clone(),
+ queue.clone(),
+ surface_configuration.format,
+ config.clone(),
+ );
// TODO multithreading introduces double-loading some resources. fix that before increasing thread count
for _ in 0..1 {
@@ -146,9 +188,11 @@ impl<'a> Renderer<'a> {
queue,
surface_configuration,
ui_renderer,
+ config,
surface_needs_reconfigure: false,
timing: Default::default(),
timing_submit: Default::default(),
+ color_msaa,
})
}
@@ -158,23 +202,38 @@ impl<'a> Renderer<'a> {
self.surface
.configure(&self.device, &self.surface_configuration);
+ let size = Extent3d {
+ height,
+ width,
+ depth_or_array_layers: 1,
+ };
self.depth = self
.device
.create_texture(&TextureDescriptor {
label: None,
- size: Extent3d {
- height,
- width,
- depth_or_array_layers: 1,
- },
+ size,
mip_level_count: 1,
- sample_count: 1,
+ sample_count: self.config.sample_count,
dimension: TextureDimension::D2,
format: TextureFormat::Depth32Float,
usage: TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
})
.create_view(&TextureViewDescriptor::default());
+
+ self.color_msaa = self
+ .device
+ .create_texture(&TextureDescriptor {
+ label: None,
+ size,
+ mip_level_count: 1,
+ sample_count: self.config.sample_count,
+ dimension: TextureDimension::D2,
+ format: self.surface_configuration.format,
+ usage: TextureUsages::RENDER_ATTACHMENT,
+ view_formats: &[],
+ })
+ .create_view(&TextureViewDescriptor::default());
}
pub fn draw(
@@ -189,12 +248,12 @@ impl<'a> Renderer<'a> {
.configure(&self.device, &self.surface_configuration);
self.surface_needs_reconfigure = false
}
- let target = self.surface.get_current_texture()?;
- if target.suboptimal {
+ let surface = self.surface.get_current_texture()?;
+ if surface.suboptimal {
warn!("suboptimal swapchain texture");
self.surface_needs_reconfigure = true;
}
- let target_view = target
+ let target_view = surface
.texture
.create_view(&TextureViewDescriptor::default());
@@ -205,10 +264,17 @@ impl<'a> Renderer<'a> {
let view = camera.view_matrix();
let project = camera.project_matrix();
+ let (target, resolve_target) = if self.config.sample_count == 1 {
+ (&target_view, None)
+ } else {
+ (&self.color_msaa, Some(&target_view))
+ };
+
self.timing.checkpoint("draw scene");
self.scene_pipeline.draw(
&mut commands,
- &target_view,
+ target,
+ resolve_target,
&self.depth,
scene,
&self.scene_prepare.prefabs,
@@ -219,7 +285,8 @@ impl<'a> Renderer<'a> {
self.timing.checkpoint("draw ui");
self.ui_renderer.draw(
&mut commands,
- &target_view,
+ target,
+ resolve_target,
&self.depth,
project * view,
input_state,
@@ -232,10 +299,9 @@ impl<'a> Renderer<'a> {
self.device.poll(MaintainBase::WaitForSubmissionIndex(i));
self.timing.checkpoint("present");
- target.present();
+ surface.present();
self.timing.checkpoint("");
-
let mut ts = self.timing_submit.lock().unwrap();
swap(&mut *ts, &mut self.timing);
diff --git a/client/src/render/scene/draw.rs b/client/src/render/scene/draw.rs
index d133d3b..1109401 100644
--- a/client/src/render/scene/draw.rs
+++ b/client/src/render/scene/draw.rs
@@ -14,7 +14,7 @@
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
-use super::{demand_map::DemandMap, RPrefab};
+use super::{RPrefab, demand_map::DemandMap};
use glam::{EulerRot, Mat3, Mat4};
use std::sync::Arc;
use weareshared::{packets::Resource, resources::Prefab, tree::SceneTree};
@@ -30,6 +30,7 @@ impl ScenePipeline {
&mut self,
commands: &mut CommandEncoder,
target: &TextureView,
+ resolve_target: Option<&TextureView>,
depth: &TextureView,
scene: &SceneTree,
prefabs: &DemandMap<Resource<Prefab>, Arc<RPrefab>>,
@@ -40,7 +41,7 @@ impl ScenePipeline {
label: None,
color_attachments: &[Some(RenderPassColorAttachment {
view: target,
- resolve_target: None,
+ resolve_target,
ops: Operations {
store: StoreOp::Store,
load: LoadOp::Clear(Color {
diff --git a/client/src/render/scene/mod.rs b/client/src/render/scene/mod.rs
index 7471fc8..ad7e0ce 100644
--- a/client/src/render/scene/mod.rs
+++ b/client/src/render/scene/mod.rs
@@ -21,7 +21,8 @@ pub mod pipelines;
pub mod textures;
pub mod vertex_buffers;
-use crate::{armature::RArmature, download::Downloader, shaders::SceneShaders};
+use super::{shaders::SceneShaders, GraphicsConfig};
+use crate::{armature::RArmature, download::Downloader};
use anyhow::Result;
use bytemuck::{Pod, Zeroable};
use demand_map::DemandMap;
@@ -42,11 +43,6 @@ use wgpu::{
util::{BufferInitDescriptor, DeviceExt},
};
-struct GraphicsConfig {
- max_anisotropy: u16,
- max_mip_count: u32,
-}
-
pub struct ScenePreparer {
device: Arc<Device>,
queue: Arc<Queue>,
@@ -143,13 +139,11 @@ impl ScenePreparer {
queue: Arc<Queue>,
render_format: TextureFormat,
downloader: Arc<Downloader>,
+ config: GraphicsConfig,
) -> Self {
Self {
render_format,
- config: GraphicsConfig {
- max_anisotropy: 16,
- max_mip_count: 16,
- },
+ config,
layouts: SceneBgLayouts::load(&device),
shaders: SceneShaders::load(&device),
device,
@@ -210,7 +204,7 @@ impl ScenePreparer {
for spec in self.pipelines.needed() {
self.pipelines.insert(
spec.clone(),
- Arc::new(spec.create(&self.device, &self.layouts, &self.shaders)),
+ Arc::new(spec.create(&self.device, &self.layouts, &self.shaders, &self.config)),
0,
);
}
diff --git a/client/src/render/scene/pipelines.rs b/client/src/render/scene/pipelines.rs
index dfc5d19..53064c9 100644
--- a/client/src/render/scene/pipelines.rs
+++ b/client/src/render/scene/pipelines.rs
@@ -14,6 +14,8 @@
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
+use super::{GraphicsConfig, PipelineSpec};
+use crate::render::shaders::SceneShaders;
use wgpu::{
BindGroupLayout, BindGroupLayoutDescriptor, BindGroupLayoutEntry, BindingType, BlendState,
BufferBindingType, ColorTargetState, ColorWrites, CompareFunction, DepthBiasState,
@@ -25,10 +27,6 @@ use wgpu::{
VertexStepMode,
};
-use crate::shaders::SceneShaders;
-
-use super::PipelineSpec;
-
pub struct SceneBgLayouts {
pub texture: BindGroupLayout,
pub material: BindGroupLayout,
@@ -95,6 +93,7 @@ impl PipelineSpec {
device: &Device,
layouts: &SceneBgLayouts,
shaders: &SceneShaders,
+ config: &GraphicsConfig,
) -> RenderPipeline {
let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
label: None,
@@ -188,7 +187,10 @@ impl PipelineSpec {
bias: DepthBiasState::default(),
stencil: StencilState::default(),
}),
- multisample: MultisampleState::default(),
+ multisample: MultisampleState {
+ count: config.sample_count,
+ ..Default::default()
+ },
multiview: None,
cache: None,
})
diff --git a/client/src/render/scene/textures.rs b/client/src/render/scene/textures.rs
index 463e8f1..f85f21f 100644
--- a/client/src/render/scene/textures.rs
+++ b/client/src/render/scene/textures.rs
@@ -32,7 +32,7 @@ pub struct MipGenerationPipeline {
}
impl MipGenerationPipeline {
pub fn load(device: &Device, format: TextureFormat) -> Self {
- let shader = device.create_shader_module(include_wgsl!("../../shaders/texture_copy.wgsl"));
+ let shader = device.create_shader_module(include_wgsl!("../shaders/texture_copy.wgsl"));
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("mip generator"),
layout: None,
diff --git a/client/src/render/shaders/fragment_pbr.wgsl b/client/src/render/shaders/fragment_pbr.wgsl
new file mode 100644
index 0000000..c8fb857
--- /dev/null
+++ b/client/src/render/shaders/fragment_pbr.wgsl
@@ -0,0 +1,64 @@
+// wearechat - generic multiplayer game with voip
+// Copyright (C) 2025 metamuffin
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, version 3 of the License only.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+struct VertexOut {
+ @builtin(position) clip: vec4<f32>,
+ @location(0) normal: vec3<f32>,
+ @location(1) tangent: vec3<f32>,
+ @location(2) texcoord: vec2<f32>,
+ @location(3) position: vec3<f32>,
+}
+
+struct Material {
+ roughness: f32,
+ metallic: f32,
+ albedo_alpha: vec4<f32>,
+ emission: vec3<f32>,
+}
+
+@group(0) @binding(0) var tex_albedo: texture_2d<f32>;
+@group(0) @binding(1) var tex_albedo_sampler: sampler;
+@group(1) @binding(0) var tex_normal: texture_2d<f32>;
+@group(1) @binding(1) var tex_normal_sampler: sampler;
+@group(2) @binding(0) var<uniform> material: Material;
+
+@fragment
+fn main(vo: VertexOut) -> @location(0) vec4<f32> {
+ let t_albedo = textureSample(tex_albedo, tex_albedo_sampler, vo.texcoord);
+ let t_normal = textureSample(tex_normal, tex_normal_sampler, vo.texcoord);
+
+ let tangent_basis = mat3x3(vo.tangent, cross(vo.tangent, vo.normal), vo.normal);
+ let normal = tangent_basis * (t_normal.rgb * 2. - 1.);
+
+ let light = vec3(0.64, 0.64, 0.64);
+ // let view = normalize(-vo.position);
+
+ let ambient = 0.1;
+ let diffuse = saturate(dot(light, normal));
+ // let specular = pow(saturate(dot(reflect(-light, normal), view)), 2.);
+
+ let lighting = ambient + diffuse;
+
+ let color = t_albedo.rgb * lighting;
+ // let color = vec3(dot(normal, view) * 0.5 + 0.5) ;
+ // let color = view * 0.5 + 0.5;
+ let alpha = t_albedo.a;
+
+ // TODO better (and faster?) randomness for alpha dither
+ if fract(dot(sin(vo.clip * 123.) * 1213., vec4(3., 2., 1., 4.))) > alpha {
+ discard;
+ }
+ return vec4(color, 1.);
+}
diff --git a/client/src/render/shaders/fragment_ui.wgsl b/client/src/render/shaders/fragment_ui.wgsl
new file mode 100644
index 0000000..7cb66ab
--- /dev/null
+++ b/client/src/render/shaders/fragment_ui.wgsl
@@ -0,0 +1,28 @@
+// wearechat - generic multiplayer game with voip
+// Copyright (C) 2025 metamuffin
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, version 3 of the License only.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+struct VertexOut {
+ @builtin(position) clip: vec4<f32>,
+ @location(0) uv: vec2<f32>,
+ @location(1) color: vec4<f32>,
+}
+
+@group(0) @binding(0) var texture: texture_2d<f32>;
+@group(0) @binding(1) var texture_sampler: sampler;
+
+@fragment
+fn main(vo: VertexOut) -> @location(0) vec4<f32> {
+ return pow(textureSample(texture, texture_sampler, vo.uv) * vo.color, vec4(2.2));
+}
diff --git a/client/src/render/shaders/mod.rs b/client/src/render/shaders/mod.rs
new file mode 100644
index 0000000..7e4e3be
--- /dev/null
+++ b/client/src/render/shaders/mod.rs
@@ -0,0 +1,37 @@
+/*
+ wearechat - generic multiplayer game with voip
+ Copyright (C) 2025 metamuffin
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, version 3 of the License only.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+*/
+use log::info;
+use wgpu::{Device, ShaderModule, include_wgsl};
+
+pub struct SceneShaders {
+ pub fragment_pbr: ShaderModule,
+ pub vertex_world: ShaderModule,
+ pub vertex_world_skin: ShaderModule,
+}
+
+impl SceneShaders {
+ pub fn load(device: &Device) -> Self {
+ info!("compiling shaders...");
+ let s = Self {
+ fragment_pbr: device.create_shader_module(include_wgsl!("fragment_pbr.wgsl")),
+ vertex_world: device.create_shader_module(include_wgsl!("vertex_world.wgsl")),
+ vertex_world_skin: device.create_shader_module(include_wgsl!("vertex_world_skin.wgsl")),
+ };
+ info!("done");
+ s
+ }
+}
diff --git a/client/src/render/shaders/texture_copy.wgsl b/client/src/render/shaders/texture_copy.wgsl
new file mode 100644
index 0000000..be38a76
--- /dev/null
+++ b/client/src/render/shaders/texture_copy.wgsl
@@ -0,0 +1,37 @@
+// wearechat - generic multiplayer game with voip
+// Copyright (C) 2025 metamuffin
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, version 3 of the License only.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+struct VertexOut {
+ @builtin(position) clip: vec4<f32>,
+ @location(0) texcoord: vec2<f32>,
+};
+
+@group(0) @binding(0) var texture: texture_2d<f32>;
+@group(0) @binding(1) var texture_sampler: sampler;
+
+@vertex
+fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOut {
+ let x = i32(vertex_index) / 2;
+ let y = i32(vertex_index) & 1;
+ return VertexOut(
+ vec4<f32>(f32(x) * 4.0 - 1.0, 1.0 - f32(y) * 4.0, 0.0, 1.0),
+ vec2<f32>(f32(x) * 2.0, f32(y) * 2.0)
+ );
+}
+
+@fragment
+fn fs_main(vertex: VertexOut) -> @location(0) vec4<f32> {
+ return textureSample(texture, texture_sampler, vertex.texcoord);
+}
diff --git a/client/src/render/shaders/vertex_ui.wgsl b/client/src/render/shaders/vertex_ui.wgsl
new file mode 100644
index 0000000..7cbac8b
--- /dev/null
+++ b/client/src/render/shaders/vertex_ui.wgsl
@@ -0,0 +1,43 @@
+// wearechat - generic multiplayer game with voip
+// Copyright (C) 2025 metamuffin
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, version 3 of the License only.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+struct VertexIn {
+ @location(0) pos: vec2<f32>,
+ @location(1) uv: vec2<f32>,
+ @location(2) color: u32,
+}
+struct VertexOut {
+ @builtin(position) clip: vec4<f32>,
+ @location(0) uv: vec2<f32>,
+ @location(1) color: vec4<f32>,
+}
+
+var<push_constant> project: mat4x4<f32>;
+
+fn unpack_color(color: u32) -> vec4<f32> {
+ return vec4<f32>(
+ f32(color & 255u),
+ f32((color >> 8u) & 255u),
+ f32((color >> 16u) & 255u),
+ f32((color >> 24u) & 255u),
+ ) / 255.0;
+}
+
+@vertex
+fn main(@builtin(vertex_index) vindex: u32, vi: VertexIn) -> VertexOut {
+ var clip = project * vec4(vi.pos, 0., 1.);
+ let vo = VertexOut(clip, vi.uv, unpack_color(vi.color));
+ return vo;
+}
diff --git a/client/src/render/shaders/vertex_world.wgsl b/client/src/render/shaders/vertex_world.wgsl
new file mode 100644
index 0000000..4f342fd
--- /dev/null
+++ b/client/src/render/shaders/vertex_world.wgsl
@@ -0,0 +1,48 @@
+// wearechat - generic multiplayer game with voip
+// Copyright (C) 2025 metamuffin
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, version 3 of the License only.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+struct VertexIn {
+ @location(0) position: vec3<f32>,
+ @location(1) normal: vec3<f32>,
+ @location(2) tangent: vec3<f32>, // TODO maybe compress this
+ @location(3) texcoord: vec2<f32>,
+}
+struct VertexOut {
+ @builtin(position) clip: vec4<f32>,
+ @location(0) normal: vec3<f32>,
+ @location(1) tangent: vec3<f32>,
+ @location(2) texcoord: vec2<f32>,
+ @location(3) position: vec3<f32>,
+}
+
+struct PushConst {
+ modelviewproject: mat4x4<f32>,
+ model: mat4x4<f32>,
+}
+
+var<push_constant> pc: PushConst;
+
+@vertex
+fn main(vi: VertexIn) -> VertexOut {
+ let clip = pc.modelviewproject * vec4(vi.position, 1.);
+ let vo = VertexOut(
+ clip,
+ normalize((pc.model * vec4(vi.normal, 0.)).xyz),
+ normalize((pc.model * vec4(vi.tangent, 0.)).xyz),
+ vi.texcoord,
+ (pc.model * vec4(vi.position, 1.)).xyz,
+ );
+ return vo;
+}
diff --git a/client/src/render/shaders/vertex_world_skin.wgsl b/client/src/render/shaders/vertex_world_skin.wgsl
new file mode 100644
index 0000000..6e2b308
--- /dev/null
+++ b/client/src/render/shaders/vertex_world_skin.wgsl
@@ -0,0 +1,58 @@
+// wearechat - generic multiplayer game with voip
+// Copyright (C) 2025 metamuffin
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, version 3 of the License only.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+struct VertexIn {
+ @location(0) position: vec3<f32>,
+ @location(1) normal: vec3<f32>,
+ @location(2) tangent: vec3<f32>, // TODO maybe compress this
+ @location(3) texcoord: vec2<f32>,
+ @location(4) joint_index: vec4<u32>,
+ @location(5) joint_weight: vec4<f32>,
+}
+struct VertexOut {
+ @builtin(position) clip: vec4<f32>,
+ @location(0) normal: vec3<f32>,
+ @location(1) tangent: vec3<f32>,
+ @location(2) texcoord: vec2<f32>,
+ @location(3) position: vec3<f32>,
+}
+
+struct PushConst {
+ modelviewproject: mat4x4<f32>,
+ model: mat4x4<f32>,
+}
+
+@group(3) @binding(0) var<uniform> joints: array<mat4x4<f32>, 128>;
+var<push_constant> pc: PushConst;
+
+@vertex
+fn main(vi: VertexIn) -> VertexOut {
+ let pos_in = vec4(vi.position, 1.);
+ let j0 = vi.joint_weight.x * (joints[vi.joint_index.x] * pos_in);
+ let j1 = vi.joint_weight.y * (joints[vi.joint_index.y] * pos_in);
+ let j2 = vi.joint_weight.z * (joints[vi.joint_index.z] * pos_in);
+ let j3 = vi.joint_weight.w * (joints[vi.joint_index.w] * pos_in);
+ let position = j0 + j1 + j2 + j3;
+
+ let clip = pc.modelviewproject * position;
+ let vo = VertexOut(
+ clip,
+ normalize((pc.model * vec4(vi.normal, 0.)).xyz),
+ normalize((pc.model * vec4(vi.tangent, 0.)).xyz),
+ vi.texcoord,
+ (pc.model * vec4(vi.position, 1.)).xyz,
+ );
+ return vo;
+}
diff --git a/client/src/render/ui.rs b/client/src/render/ui.rs
new file mode 100644
index 0000000..e27b9cb
--- /dev/null
+++ b/client/src/render/ui.rs
@@ -0,0 +1,535 @@
+/*
+ wearechat - generic multiplayer game with voip
+ Copyright (C) 2025 metamuffin
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, version 3 of the License only.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+*/
+use super::GraphicsConfig;
+use crate::state::InputState;
+use egui::{
+ Context, Event, ImageData, PointerButton, TextureId, ViewportId, ViewportInfo,
+ epaint::{ImageDelta, Primitive, Vertex},
+};
+use glam::{Affine3A, Mat2, Mat3, Mat4, Vec2, Vec3, Vec3Swizzles, Vec4Swizzles, vec2, vec4};
+use log::{debug, warn};
+use rand::random;
+use std::{
+ collections::HashMap,
+ num::NonZeroU64,
+ sync::{Arc, RwLock},
+};
+use wgpu::{
+ AddressMode, BindGroup, BindGroupDescriptor, BindGroupEntry, BindGroupLayout,
+ BindGroupLayoutDescriptor, BindGroupLayoutEntry, BindingResource, BindingType, BlendState,
+ Buffer, BufferDescriptor, BufferUsages, ColorTargetState, ColorWrites, CommandEncoder,
+ CompareFunction, DepthStencilState, Device, Extent3d, FilterMode, FragmentState, FrontFace,
+ ImageCopyTexture, ImageDataLayout, IndexFormat, LoadOp, MultisampleState, Operations, Origin3d,
+ PipelineCompilationOptions, PipelineLayoutDescriptor, PolygonMode, PrimitiveState,
+ PrimitiveTopology, PushConstantRange, Queue, RenderPassColorAttachment,
+ RenderPassDepthStencilAttachment, RenderPassDescriptor, RenderPipeline,
+ RenderPipelineDescriptor, SamplerBindingType, SamplerDescriptor, ShaderStages, StoreOp,
+ SurfaceConfiguration, Texture, TextureAspect, TextureDescriptor, TextureDimension,
+ TextureFormat, TextureSampleType, TextureUsages, TextureView, TextureViewDescriptor,
+ TextureViewDimension, VertexBufferLayout, VertexState, VertexStepMode, include_wgsl,
+ util::{DeviceExt, TextureDataOrder},
+ vertex_attr_array,
+};
+use winit::event::MouseButton;
+
+pub const UI_POSITION_OFFSET: f32 = 1000.;
+
+pub struct UiRenderer {
+ device: Arc<Device>,
+ queue: Arc<Queue>,
+ _config: GraphicsConfig,
+ ctx: Context,
+ pipeline: RenderPipeline,
+ bind_group_layout: BindGroupLayout,
+ textures: RwLock<HashMap<TextureId, (BindGroup, Texture, [u32; 2])>>,
+ surfaces: RwLock<HashMap<ViewportId, UiSurface>>,
+
+ last_pointer: Vec2,
+}
+
+pub struct UiSurface {
+ pub transform: Affine3A,
+ pub content: Arc<dyn Fn(&Context) -> bool + Send + Sync + 'static>,
+ size: Vec2,
+ index: Buffer,
+ index_capacity: usize,
+ vertex: Buffer,
+ vertex_capacity: usize,
+}
+
+pub enum UiEvent {
+ Click(Vec2, MouseButton, bool),
+}
+
+impl UiRenderer {
+ pub fn new(
+ device: Arc<Device>,
+ queue: Arc<Queue>,
+ format: TextureFormat,
+ config: GraphicsConfig,
+ ) -> Self {
+ let frag_shader = device.create_shader_module(include_wgsl!("shaders/fragment_ui.wgsl"));
+ let vert_shader = device.create_shader_module(include_wgsl!("shaders/vertex_ui.wgsl"));
+
+ let bind_group_layout = device.create_bind_group_layout(&BindGroupLayoutDescriptor {
+ entries: &[
+ BindGroupLayoutEntry {
+ binding: 0,
+ count: None,
+ visibility: ShaderStages::FRAGMENT,
+ ty: BindingType::Texture {
+ sample_type: TextureSampleType::Float { filterable: true },
+ view_dimension: TextureViewDimension::D2,
+ multisampled: false,
+ },
+ },
+ BindGroupLayoutEntry {
+ binding: 1,
+ count: None,
+ visibility: ShaderStages::FRAGMENT,
+ ty: BindingType::Sampler(SamplerBindingType::Filtering),
+ },
+ ],
+ label: None,
+ });
+ let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
+ label: None,
+ bind_group_layouts: &[&bind_group_layout],
+ push_constant_ranges: &[PushConstantRange {
+ range: 0..(4 * 4 * size_of::<f32>() as u32),
+ stages: ShaderStages::VERTEX,
+ }],
+ });
+ let pipeline = device.create_render_pipeline(&RenderPipelineDescriptor {
+ label: None,
+ layout: Some(&pipeline_layout),
+ fragment: Some(FragmentState {
+ module: &frag_shader,
+ entry_point: Some("main"),
+ targets: &[Some(ColorTargetState {
+ blend: Some(BlendState::PREMULTIPLIED_ALPHA_BLENDING),
+ format,
+ write_mask: ColorWrites::all(),
+ })],
+ compilation_options: PipelineCompilationOptions::default(),
+ }),
+ vertex: VertexState {
+ module: &vert_shader,
+ entry_point: Some("main"),
+ buffers: &[VertexBufferLayout {
+ array_stride: size_of::<Vertex>() as u64,
+ step_mode: VertexStepMode::Vertex,
+ attributes: &vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Uint32],
+ }],
+ compilation_options: PipelineCompilationOptions::default(),
+ },
+ primitive: PrimitiveState {
+ topology: PrimitiveTopology::TriangleList,
+ front_face: FrontFace::Ccw,
+ cull_mode: None, //Some(Face::Back),
+ polygon_mode: PolygonMode::Fill,
+ ..Default::default()
+ },
+ depth_stencil: Some(DepthStencilState {
+ format: TextureFormat::Depth32Float,
+ depth_write_enabled: false,
+ depth_compare: CompareFunction::LessEqual,
+ stencil: Default::default(),
+ bias: Default::default(),
+ }),
+ multisample: MultisampleState {
+ count: config.sample_count,
+ ..Default::default()
+ },
+ multiview: None,
+ cache: None,
+ });
+ Self {
+ ctx: Context::default(),
+ pipeline,
+ device,
+ queue,
+ bind_group_layout,
+ _config: config,
+ last_pointer: Vec2::ZERO,
+ textures: HashMap::new().into(),
+ surfaces: HashMap::new().into(),
+ }
+ }
+
+ pub fn add_surface(
+ &mut self,
+ transform: Affine3A,
+ content: impl Fn(&Context) -> bool + Send + Sync + 'static,
+ ) {
+ let index_capacity = 1024;
+ let vertex_capacity = 1024;
+ let index = self.device.create_buffer(&BufferDescriptor {
+ label: None,
+ size: (size_of::<f32>() * index_capacity) as u64,
+ usage: BufferUsages::INDEX | BufferUsages::COPY_DST,
+ mapped_at_creation: false,
+ });
+ let vertex = self.device.create_buffer(&BufferDescriptor {
+ label: None,
+ size: (size_of::<Vertex>() * vertex_capacity) as u64,
+ usage: BufferUsages::VERTEX | BufferUsages::COPY_DST,
+ mapped_at_creation: false,
+ });
+ let id = ViewportId::from_hash_of(random::<u128>());
+ debug!("ui surface added: {id:?}");
+ self.surfaces.write().unwrap().insert(id, UiSurface {
+ transform,
+ content: Arc::new(content),
+ index,
+ vertex,
+ index_capacity,
+ vertex_capacity,
+ size: Vec2::ZERO,
+ });
+ }
+
+ pub fn apply_texture_delta(&self, texid: TextureId, delta: ImageDelta) {
+ let mut textures = self.textures.write().unwrap();
+ let size = Extent3d {
+ depth_or_array_layers: 1,
+ width: delta.image.width() as u32,
+ height: delta.image.height() as u32,
+ };
+ let pixels = match &delta.image {
+ ImageData::Color(color_image) => color_image.pixels.clone(),
+ ImageData::Font(font_image) => font_image.srgba_pixels(None).collect(),
+ };
+
+ if let Some((_texbg, tex, texsize)) = textures.get_mut(&texid) {
+ let pos = delta.pos.unwrap_or([0, 0]);
+ debug!("updating UI texture at {pos:?}");
+ self.queue.write_texture(
+ ImageCopyTexture {
+ texture: &tex,
+ mip_level: 0,
+ origin: Origin3d {
+ x: pos[0] as u32,
+ y: pos[1] as u32,
+ z: 0,
+ },
+ aspect: TextureAspect::All,
+ },
+ bytemuck::cast_slice::<_, u8>(&pixels),
+ ImageDataLayout {
+ offset: 0,
+ bytes_per_row: Some(texsize[0] * 4),
+ rows_per_image: None,
+ },
+ size,
+ );
+ } else {
+ assert_eq!(
+ delta.pos, None,
+ "partial update impossible; texture does not yet exist"
+ );
+ debug!(
+ "uploading new UI texture: width={}, height={}",
+ delta.image.width(),
+ delta.image.height()
+ );
+
+ let texture = self.device.create_texture_with_data(
+ &self.queue,
+ &TextureDescriptor {
+ label: None,
+ size,
+ mip_level_count: 1,
+ sample_count: 1,
+ dimension: TextureDimension::D2,
+ format: TextureFormat::Rgba8UnormSrgb,
+ usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST,
+ view_formats: &[],
+ },
+ TextureDataOrder::LayerMajor,
+ bytemuck::cast_slice::<_, u8>(&pixels),
+ );
+ let textureview = texture.create_view(&TextureViewDescriptor::default());
+ let sampler = self.device.create_sampler(&SamplerDescriptor {
+ address_mode_u: AddressMode::ClampToEdge,
+ address_mode_v: AddressMode::ClampToEdge,
+ mag_filter: FilterMode::Linear,
+ min_filter: FilterMode::Linear,
+ ..Default::default()
+ });
+ let bindgroup = self.device.create_bind_group(&BindGroupDescriptor {
+ label: None,
+ layout: &self.bind_group_layout,
+ entries: &[
+ BindGroupEntry {
+ binding: 0,
+ resource: BindingResource::TextureView(&textureview),
+ },
+ BindGroupEntry {
+ binding: 1,
+ resource: BindingResource::Sampler(&sampler),
+ },
+ ],
+ });
+ textures.insert(
+ texid,
+ (bindgroup, texture, delta.image.size().map(|e| e as u32)),
+ );
+ }
+ }
+
+ pub fn draw(
+ &mut self,
+ commands: &mut CommandEncoder,
+ target: &TextureView,
+ resolve_target: Option<&TextureView>,
+ depth: &TextureView,
+ projection: Mat4,
+ input_state: &mut InputState,
+ surface_configuration: &SurfaceConfiguration,
+ ) {
+ let mut surfaces = self.surfaces.write().unwrap();
+ if surfaces.is_empty() {
+ return;
+ }
+
+ let mut rpass = commands.begin_render_pass(&RenderPassDescriptor {
+ label: None,
+ color_attachments: &[Some(RenderPassColorAttachment {
+ view: target,
+ resolve_target,
+ ops: Operations {
+ load: LoadOp::Load,
+ store: StoreOp::Store,
+ },
+ })],
+ depth_stencil_attachment: Some(RenderPassDepthStencilAttachment {
+ view: &depth,
+ depth_ops: Some(Operations {
+ load: LoadOp::Load,
+ store: StoreOp::Store,
+ }),
+ stencil_ops: None,
+ }),
+ ..Default::default()
+ });
+
+ rpass.set_pipeline(&self.pipeline);
+
+ let mut raw_input = egui::RawInput::default();
+ raw_input.viewport_id = surfaces.keys().next().copied().unwrap();
+ raw_input.viewports = surfaces
+ .keys()
+ .map(|k| {
+ (*k, ViewportInfo {
+ native_pixels_per_point: Some(2.),
+ ..Default::default()
+ })
+ })
+ .collect();
+
+ let mut surfaces_closed = Vec::new();
+ for (viewport_id, surf) in surfaces.iter_mut() {
+ let scale = 0.005;
+ let projection = projection
+ * Mat4::from_translation(surf.transform.translation.into())
+ * Mat4::from_mat3a(surf.transform.matrix3)
+ * Mat4::from_mat3(Mat3::from_mat2(Mat2::from_cols_array(&[
+ scale, 0., 0., -scale,
+ ])))
+ * Mat4::from_translation(-Vec3::new(UI_POSITION_OFFSET, UI_POSITION_OFFSET, 0.));
+
+ let screen_size = vec2(
+ surface_configuration.width as f32,
+ surface_configuration.height as f32,
+ );
+
+ if projection.determinant() < 0e-4 {
+ warn!("bad UI projection")
+ }
+
+ let unproject = projection.inverse();
+
+ let unproject_mouse = |pos: Vec2| {
+ let mouse_xy_clip = (pos / screen_size) * 2. - 1.;
+
+ let mouse_clip_1 = vec4(mouse_xy_clip.x, -mouse_xy_clip.y, 0.0, 1.0);
+ let mouse_clip_2 = vec4(mouse_xy_clip.x, -mouse_xy_clip.y, 1.0, 1.0);
+ let mut mouse_world_1 = unproject * mouse_clip_1;
+ let mut mouse_world_2 = unproject * mouse_clip_2;
+ mouse_world_1 /= mouse_world_1.w;
+ mouse_world_2 /= mouse_world_2.w;
+ let mouse_world_1 = mouse_world_1.xyz();
+ let mouse_world_2 = mouse_world_2.xyz();
+
+ let ray_norm = (mouse_world_2 - mouse_world_1).normalize();
+ let ray_t = mouse_world_1.z / ray_norm.z;
+ let ray_hit = mouse_world_1 - ray_norm * ray_t;
+
+ debug_assert!(ray_hit.z.abs() < 0.1, "mouse was not projected properly");
+
+ ray_hit.xy()
+ };
+
+ let cursor_pos = unproject_mouse(input_state.cursor_pos);
+
+ let mut raw_input = raw_input.clone();
+ if cursor_pos != self.last_pointer {
+ raw_input.events.push(Event::PointerMoved(egui::Pos2::new(
+ cursor_pos.x,
+ cursor_pos.y,
+ )));
+ self.last_pointer = cursor_pos;
+ }
+ raw_input
+ .events
+ .extend(input_state.ui_events.iter().map(|e| match e {
+ UiEvent::Click(pos, button, down) => egui::Event::PointerButton {
+ pos: egui::Pos2::from(unproject_mouse(*pos).to_array()),
+ button: match button {
+ MouseButton::Left => PointerButton::Primary,
+ MouseButton::Right => PointerButton::Secondary,
+ MouseButton::Middle => PointerButton::Middle,
+ MouseButton::Back => PointerButton::Extra1,
+ MouseButton::Forward => PointerButton::Extra2,
+ MouseButton::Other(_) => PointerButton::Extra1,
+ },
+ pressed: *down,
+ modifiers: egui::Modifiers::default(),
+ },
+ }));
+
+ let mut close = false;
+ let full_output = self.ctx.run(raw_input.clone(), |ctx| {
+ close = !(surf.content)(ctx);
+ surf.size = Vec2::new(ctx.used_size().x, ctx.used_size().y)
+ });
+ if close {
+ surfaces_closed.push(*viewport_id)
+ }
+
+ for (texid, delta) in full_output.textures_delta.set {
+ self.apply_texture_delta(texid, delta);
+ }
+
+ let clipped_primitives = self
+ .ctx
+ .tessellate(full_output.shapes, full_output.pixels_per_point);
+
+ let mut index_count = 0;
+ let mut vertex_count = 0;
+ for p in &clipped_primitives {
+ if let Primitive::Mesh(mesh) = &p.primitive {
+ index_count += mesh.indices.len();
+ vertex_count += mesh.vertices.len();
+ }
+ }
+
+ if index_count == 0 || vertex_count == 0 {
+ return;
+ }
+
+ while index_count > surf.index_capacity {
+ debug!(
+ "index buffer overflow ({index_count}). expanding {} -> {}",
+ surf.index_capacity,
+ surf.index_capacity * 2
+ );
+ surf.index_capacity *= 2;
+ surf.index = self.device.create_buffer(&BufferDescriptor {
+ label: None,
+ size: (size_of::<u32>() * surf.index_capacity) as u64,
+ usage: BufferUsages::INDEX | BufferUsages::COPY_DST,
+ mapped_at_creation: false,
+ });
+ }
+ while vertex_count > surf.vertex_capacity {
+ debug!(
+ "vertex buffer overflow ({vertex_count}). expanding {} -> {}",
+ surf.vertex_capacity,
+ surf.vertex_capacity * 2
+ );
+ surf.vertex_capacity *= 2;
+ surf.vertex = self.device.create_buffer(&BufferDescriptor {
+ label: None,
+ size: (size_of::<Vertex>() * surf.vertex_capacity) as u64,
+ usage: BufferUsages::VERTEX | BufferUsages::COPY_DST,
+ mapped_at_creation: false,
+ });
+ }
+
+ let mut mapped_index = self
+ .queue
+ .write_buffer_with(
+ &surf.index,
+ 0,
+ NonZeroU64::new((size_of::<u32>() * index_count) as u64).unwrap(),
+ )
+ .expect("ui index buffer overflow");
+ let mut mapped_vertex = self
+ .queue
+ .write_buffer_with(
+ &surf.vertex,
+ 0,
+ NonZeroU64::new((size_of::<Vertex>() * vertex_count) as u64).unwrap(),
+ )
+ .expect("ui vertex buffer overflow");
+
+ let mut index_offset = 0;
+ let mut vertex_offset = 0;
+ let mut slices = Vec::new();
+ for p in clipped_primitives {
+ if let Primitive::Mesh(mesh) = p.primitive {
+ mapped_index[index_offset * size_of::<u32>()
+ ..(index_offset + mesh.indices.len()) * size_of::<u32>()]
+ .copy_from_slice(bytemuck::cast_slice(&mesh.indices));
+ mapped_vertex[vertex_offset * size_of::<Vertex>()
+ ..(vertex_offset + mesh.vertices.len()) * size_of::<Vertex>()]
+ .copy_from_slice(bytemuck::cast_slice(&mesh.vertices));
+ slices.push((
+ index_offset as u32..index_offset as u32 + mesh.indices.len() as u32,
+ vertex_offset as i32,
+ mesh.texture_id,
+ ));
+ index_offset += mesh.indices.len();
+ vertex_offset += mesh.vertices.len();
+ }
+ }
+
+ assert_eq!(index_count, index_offset);
+ assert_eq!(vertex_count, vertex_offset);
+
+ let projection = projection.to_cols_array().map(|v| v.to_le_bytes());
+
+ rpass.set_push_constants(ShaderStages::VERTEX, 0, projection.as_flattened());
+ rpass.set_index_buffer(surf.index.slice(..), IndexFormat::Uint32);
+ rpass.set_vertex_buffer(0, surf.vertex.slice(..));
+ for (index, base_vertex, texid) in slices {
+ let tex_guard = self.textures.read().unwrap();
+ let bind_group = &tex_guard.get(&texid).unwrap().0;
+ rpass.set_bind_group(0, bind_group, &[]);
+ rpass.draw_indexed(index, base_vertex, 0..1);
+ }
+ }
+ for s in surfaces_closed {
+ debug!("ui surface closed: {s:?}");
+ surfaces.remove(&s);
+ }
+
+ input_state.ui_events.clear();
+ }
+}