Phase 3: extract render/ module from state.rs

state.rs is now 870 lines (down from 1700+). All GPU code moved to a
dedicated render/ module.

New src/render/:
  mod.rs           Renderer struct + impl with all methods; the only
                   place that owns wgpu device/surface/pipelines. The
                   wasm-only WebGPU probe + compat-error overlay live
                   here as a private wasm_compat sub-module.
  pipelines.rs     Pipeline factory functions: camera_bgl, post_bgl,
                   pipeline_layout, sky_pipeline, terrain_pipeline,
                   outline_pipeline, post_pipeline. Each takes
                   device + shader + format and returns a configured
                   wgpu::RenderPipeline — pure factories, no hidden
                   state.
  scene_target.rs  create_depth_view, create_scene_color_view,
                   create_post_bind_group — fresh-resource builders
                   called from Renderer::new and Renderer::resize.
  uniform.rs       CameraUniform (matches WGSL camera.frame layout),
                   OutlineVertex, ChunkBuffers.

state.rs:
  - drops ~760 lines of pipeline + scene-target plumbing
  - imports Renderer from crate::render
  - keeps App + tick + drain_net_inbox + thread_locals + wasm_api

Tests: 53/53 pass. Native + wasm release build clean.
This commit is contained in:
Maximus Gorog 2026-05-23 23:21:47 -06:00
parent accbf67bf2
commit 549662ddc8
6 changed files with 988 additions and 885 deletions

View file

@ -2,6 +2,7 @@ pub mod camera;
pub mod mesh;
pub mod net;
pub mod proto;
pub mod render;
pub mod shader_source;
pub mod sim;
pub mod state;

631
src/render/mod.rs Normal file
View file

@ -0,0 +1,631 @@
//! GPU shell. Owns the wgpu device + surface + pipelines + per-chunk
//! buffers + remote-player buffers + offscreen scene-color target +
//! post pipeline. Everything visual happens here; pure simulation
//! (sim/) and net parsing (net/) feed it via the App tick.
//!
//! Sub-modules:
//! pipelines.rs pipeline + bind-group-layout factories
//! scene_target.rs depth / scene-color / post bind-group helpers
//! uniform.rs CameraUniform + OutlineVertex + ChunkBuffers
pub mod pipelines;
pub mod scene_target;
pub mod uniform;
use crate::camera::Camera;
use crate::mesh::{build_chunk_mesh, emit_oriented_box, name_hash, Vertex};
use crate::state::RemotePlayer;
use crate::world::World;
use glam::{IVec3, Vec3};
use std::collections::HashMap;
use std::sync::Arc;
use uniform::{CameraUniform, ChunkBuffers, OutlineVertex};
use wgpu::util::DeviceExt;
use winit::window::Window;
const OUTLINE_VERT_COUNT: u64 = 24;
const MAX_REMOTE_PLAYERS: u64 = 32;
// 2 boxes per remote player (body + head); each box = 6 faces × 4 verts
// and 6 faces × 6 indices.
const REMOTE_VERTS_PER_PLAYER: u64 = 2 * 24;
const REMOTE_INDICES_PER_PLAYER: u64 = 2 * 36;
pub struct Renderer {
surface: wgpu::Surface<'static>,
device: wgpu::Device,
queue: wgpu::Queue,
config: wgpu::SurfaceConfiguration,
pipeline: wgpu::RenderPipeline,
sky_pipeline: wgpu::RenderPipeline,
outline_pipeline: wgpu::RenderPipeline,
post_pipeline: wgpu::RenderPipeline,
outline_buffer: wgpu::Buffer,
depth_view: wgpu::TextureView,
camera_buffer: wgpu::Buffer,
camera_bind_group: wgpu::BindGroup,
pub window: Arc<Window>,
chunk_buffers: HashMap<IVec3, ChunkBuffers>,
outline_target: Option<IVec3>,
visible_chunks: Vec<IVec3>,
remote_vb: wgpu::Buffer,
remote_ib: wgpu::Buffer,
remote_index_count: u32,
// ---- Post processing (Step 1: pass-through scene → surface) ----
scene_color: wgpu::TextureView,
scene_color_format: wgpu::TextureFormat,
post_sampler: wgpu::Sampler,
post_bgl: wgpu::BindGroupLayout,
post_bind_group: wgpu::BindGroup,
}
impl Renderer {
pub async fn new(window: Arc<Window>) -> Self {
let size = window.inner_size();
#[allow(unused_mut)]
let (mut width, mut height) = (size.width.max(1), size.height.max(1));
#[cfg(target_arch = "wasm32")]
{
if width <= 2 || height <= 2 {
if let Some(c) = web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.get_element_by_id("game-canvas"))
.and_then(|e| {
wasm_bindgen::JsCast::dyn_into::<web_sys::HtmlCanvasElement>(e).ok()
})
{
let w = c.width().max(1);
let h = c.height().max(1);
log::info!("overriding inner_size {}x{} with canvas {}x{}", width, height, w, h);
width = w;
height = h;
}
}
}
log::info!("initial surface size: {}x{}", width, height);
#[cfg(target_arch = "wasm32")]
let backends = {
let webgpu_ok = wasm_compat::detect_webgpu().await;
if webgpu_ok {
log::info!("WebGPU adapter probe OK — using WebGPU backend");
wgpu::Backends::BROWSER_WEBGPU
} else {
log::info!("WebGPU unavailable — using WebGL2 backend");
wgpu::Backends::GL
}
};
#[cfg(not(target_arch = "wasm32"))]
let backends = wgpu::Backends::PRIMARY | wgpu::Backends::GL;
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
backends,
..Default::default()
});
let surface = match instance.create_surface(window.clone()) {
Ok(s) => s,
Err(e) => {
log::error!("create_surface failed: {e:?}");
#[cfg(target_arch = "wasm32")]
wasm_compat::show_browser_compat_error(&format!("{e}"));
panic!("could not create rendering surface: {e:?}");
}
};
let adapter = match instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: Some(&surface),
force_fallback_adapter: false,
})
.await
{
Some(a) => a,
None => {
#[cfg(target_arch = "wasm32")]
wasm_compat::show_browser_compat_error(
"No GPU adapter available. WebGL2/WebGPU may be disabled in your browser.",
);
panic!("no suitable GPU adapter");
}
};
let info = adapter.get_info();
log::info!(
"wgpu adapter: backend={:?} type={:?} name={:?}",
info.backend,
info.device_type,
info.name,
);
let primary_limits = if matches!(info.backend, wgpu::Backend::BrowserWebGpu) {
wgpu::Limits::default()
} else {
wgpu::Limits::downlevel_webgl2_defaults()
};
let (device, queue) = match adapter
.request_device(
&wgpu::DeviceDescriptor {
label: Some("device"),
required_features: wgpu::Features::empty(),
required_limits: primary_limits,
memory_hints: wgpu::MemoryHints::Performance,
},
None,
)
.await
{
Ok(dq) => dq,
Err(e) => {
log::warn!("device request failed ({e:?}); retrying with downlevel limits");
adapter
.request_device(
&wgpu::DeviceDescriptor {
label: Some("device-fallback"),
required_features: wgpu::Features::empty(),
required_limits: wgpu::Limits::downlevel_webgl2_defaults(),
memory_hints: wgpu::MemoryHints::Performance,
},
None,
)
.await
.expect("device request failed even with downlevel limits")
}
};
let caps = surface.get_capabilities(&adapter);
let format = caps
.formats
.iter()
.copied()
.find(|f| f.is_srgb())
.unwrap_or(caps.formats[0]);
let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format,
width,
height,
present_mode: wgpu::PresentMode::Fifo,
alpha_mode: caps.alpha_modes[0],
view_formats: vec![],
desired_maximum_frame_latency: 2,
};
surface.configure(&device, &config);
let depth_view = scene_target::create_depth_view(&device, width, height);
// ---- Shaders (terrain + post) ----
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("terrain shader"),
source: wgpu::ShaderSource::Wgsl(
crate::shader_source::terrain_shader_source().into(),
),
});
let post_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("post shader"),
source: wgpu::ShaderSource::Wgsl(crate::shader_source::post_shader_source().into()),
});
// ---- Camera uniform + layouts + pipelines ----
let camera_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("camera"),
contents: bytemuck::bytes_of(&CameraUniform::identity()),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let camera_bgl = pipelines::camera_bgl(&device);
let camera_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("camera bg"),
layout: &camera_bgl,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: camera_buffer.as_entire_binding(),
}],
});
let pl = pipelines::pipeline_layout(&device, "pl", &[&camera_bgl]);
let sky_pipeline = pipelines::sky_pipeline(&device, &pl, &shader, config.format);
let pipeline = pipelines::terrain_pipeline(&device, &pl, &shader, config.format);
let outline_pipeline = pipelines::outline_pipeline(&device, &pl, &shader, config.format);
// ---- Buffers for outline + remote players ----
let outline_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("outline buf"),
size: OUTLINE_VERT_COUNT * std::mem::size_of::<OutlineVertex>() as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let remote_vb = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("remote vb"),
size: MAX_REMOTE_PLAYERS * REMOTE_VERTS_PER_PLAYER
* std::mem::size_of::<Vertex>() as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let remote_ib = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("remote ib"),
size: MAX_REMOTE_PLAYERS * REMOTE_INDICES_PER_PLAYER * 4,
usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// ---- Post pipeline + scene-color target ----
let scene_color_format = config.format;
let scene_color =
scene_target::create_scene_color_view(&device, width, height, scene_color_format);
let post_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("post sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
address_mode_w: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::FilterMode::Nearest,
..Default::default()
});
let post_bgl = pipelines::post_bgl(&device);
let post_bind_group =
scene_target::create_post_bind_group(&device, &post_bgl, &scene_color, &post_sampler);
let post_pl = pipelines::pipeline_layout(&device, "post pl", &[&post_bgl]);
let post_pipeline = pipelines::post_pipeline(&device, &post_pl, &post_shader, config.format);
Self {
surface,
device,
queue,
config,
pipeline,
sky_pipeline,
outline_pipeline,
outline_buffer,
depth_view,
camera_buffer,
camera_bind_group,
window,
chunk_buffers: HashMap::new(),
outline_target: None,
visible_chunks: Vec::new(),
remote_vb,
remote_ib,
remote_index_count: 0,
scene_color,
scene_color_format,
post_sampler,
post_bgl,
post_bind_group,
post_pipeline,
}
}
pub fn set_remote_players(&mut self, players: &[RemotePlayer]) {
if players.is_empty() {
self.remote_index_count = 0;
return;
}
let mut verts: Vec<Vertex> = Vec::with_capacity(players.len() * 48);
let mut indices: Vec<u32> = Vec::with_capacity(players.len() * 72);
let max = players.len().min(MAX_REMOTE_PLAYERS as usize);
for p in &players[..max] {
let h = name_hash(&p.name);
let r = 0.35 + (h & 0x3F) as f32 / 255.0;
let g = 0.35 + ((h >> 8) & 0x3F) as f32 / 255.0;
let b = 0.35 + ((h >> 16) & 0x3F) as f32 / 255.0;
let body = [r, g, b];
let head = [r * 0.85 + 0.15, g * 0.85 + 0.15, b * 0.85 + 0.15];
emit_oriented_box(
Vec3::new(p.pos.x, p.pos.y + 0.65, p.pos.z),
Vec3::new(0.3, 0.65, 0.2),
p.yaw,
body,
&mut verts,
&mut indices,
);
emit_oriented_box(
Vec3::new(p.pos.x, p.pos.y + 1.55, p.pos.z),
Vec3::new(0.25, 0.25, 0.25),
p.yaw,
head,
&mut verts,
&mut indices,
);
}
self.remote_index_count = indices.len() as u32;
self.queue
.write_buffer(&self.remote_vb, 0, bytemuck::cast_slice(&verts));
self.queue
.write_buffer(&self.remote_ib, 0, bytemuck::cast_slice(&indices));
}
pub fn set_outline(&mut self, target: Option<IVec3>) {
if self.outline_target == target {
return;
}
self.outline_target = target;
if let Some(b) = target {
let eps = 0.002;
let min = [b.x as f32 - eps, b.y as f32 - eps, b.z as f32 - eps];
let max = [
b.x as f32 + 1.0 + eps,
b.y as f32 + 1.0 + eps,
b.z as f32 + 1.0 + eps,
];
let c000 = [min[0], min[1], min[2]];
let c100 = [max[0], min[1], min[2]];
let c001 = [min[0], min[1], max[2]];
let c101 = [max[0], min[1], max[2]];
let c010 = [min[0], max[1], min[2]];
let c110 = [max[0], max[1], min[2]];
let c011 = [min[0], max[1], max[2]];
let c111 = [max[0], max[1], max[2]];
let verts = [
c000, c100, c100, c101, c101, c001, c001, c000, c010, c110, c110, c111, c111,
c011, c011, c010, c000, c010, c100, c110, c101, c111, c001, c011,
];
let data: Vec<OutlineVertex> = verts.iter().map(|p| OutlineVertex { pos: *p }).collect();
self.queue
.write_buffer(&self.outline_buffer, 0, bytemuck::cast_slice(&data));
}
}
pub fn resize(&mut self, width: u32, height: u32) {
if width == 0 || height == 0 {
return;
}
self.config.width = width;
self.config.height = height;
self.surface.configure(&self.device, &self.config);
self.depth_view = scene_target::create_depth_view(&self.device, width, height);
self.scene_color = scene_target::create_scene_color_view(
&self.device,
width,
height,
self.scene_color_format,
);
self.post_bind_group = scene_target::create_post_bind_group(
&self.device,
&self.post_bgl,
&self.scene_color,
&self.post_sampler,
);
}
pub fn rebuild_chunk(&mut self, coord: IVec3, world: &World) {
let Some(chunk) = world.chunks.get(&coord) else {
return;
};
let mesh = build_chunk_mesh(world, chunk);
if mesh.indices.is_empty() {
self.chunk_buffers.remove(&coord);
return;
}
let vertex = self
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("chunk vb"),
contents: bytemuck::cast_slice(&mesh.vertices),
usage: wgpu::BufferUsages::VERTEX,
});
let index = self
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("chunk ib"),
contents: bytemuck::cast_slice(&mesh.indices),
usage: wgpu::BufferUsages::INDEX,
});
self.chunk_buffers.insert(
coord,
ChunkBuffers {
vertex,
index,
index_count: mesh.indices.len() as u32,
},
);
}
pub fn upload_camera(&self, camera: &Camera, time: f32) {
let vp = camera.view_proj();
let inv = vp.inverse();
let uni = CameraUniform {
view_proj: vp.to_cols_array_2d(),
inv_view_proj: inv.to_cols_array_2d(),
eye: [camera.position.x, camera.position.y, camera.position.z, 1.0],
frame: [time, 0.0, 0.0, 0.0],
};
self.queue
.write_buffer(&self.camera_buffer, 0, bytemuck::bytes_of(&uni));
}
pub fn set_visible(&mut self, chunks: Vec<IVec3>) {
self.visible_chunks = chunks;
}
pub fn render(&self) -> Result<(), wgpu::SurfaceError> {
let frame = self.surface.get_current_texture()?;
let surface_view = frame
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("enc") });
// ---- Scene pass: render the world into the offscreen scene_color. ----
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("scene pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.scene_color,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: 0.30,
g: 0.55,
b: 0.88,
a: 1.0,
}),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_pipeline(&self.sky_pipeline);
pass.set_bind_group(0, &self.camera_bind_group, &[]);
pass.draw(0..3, 0..1);
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &self.camera_bind_group, &[]);
let iter: Box<dyn Iterator<Item = &ChunkBuffers>> = if self.visible_chunks.is_empty() {
Box::new(self.chunk_buffers.values())
} else {
Box::new(self.visible_chunks.iter().filter_map(|c| self.chunk_buffers.get(c)))
};
for buf in iter {
pass.set_vertex_buffer(0, buf.vertex.slice(..));
pass.set_index_buffer(buf.index.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(0..buf.index_count, 0, 0..1);
}
if self.remote_index_count > 0 {
pass.set_vertex_buffer(0, self.remote_vb.slice(..));
pass.set_index_buffer(self.remote_ib.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(0..self.remote_index_count, 0, 0..1);
}
if self.outline_target.is_some() {
pass.set_pipeline(&self.outline_pipeline);
pass.set_bind_group(0, &self.camera_bind_group, &[]);
pass.set_vertex_buffer(0, self.outline_buffer.slice(..));
pass.draw(0..OUTLINE_VERT_COUNT as u32, 0..1);
}
}
// ---- Post pass: scene_color → surface (effects later). ----
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("post pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &surface_view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_pipeline(&self.post_pipeline);
pass.set_bind_group(0, &self.post_bind_group, &[]);
pass.draw(0..3, 0..1);
}
self.queue.submit(Some(encoder.finish()));
frame.present();
Ok(())
}
}
// ---- Browser-only compat helpers (probe WebGPU, render init-failure overlay) ----
#[cfg(target_arch = "wasm32")]
mod wasm_compat {
pub async fn detect_webgpu() -> bool {
use wasm_bindgen::{JsCast, JsValue};
let Some(win) = web_sys::window() else {
log::info!("detect_webgpu: no window");
return false;
};
let nav: JsValue = win.navigator().into();
let gpu = match js_sys::Reflect::get(&nav, &JsValue::from_str("gpu")) {
Ok(v) if !v.is_undefined() && !v.is_null() => v,
_ => {
log::info!("detect_webgpu: navigator.gpu missing");
return false;
}
};
let req = match js_sys::Reflect::get(&gpu, &JsValue::from_str("requestAdapter")) {
Ok(v) => v,
Err(_) => {
log::info!("detect_webgpu: requestAdapter prop missing");
return false;
}
};
let req_fn: js_sys::Function = match req.dyn_into() {
Ok(f) => f,
Err(_) => {
log::info!("detect_webgpu: requestAdapter not callable");
return false;
}
};
let promise = match req_fn.call0(&gpu) {
Ok(p) => p,
Err(e) => {
log::info!("detect_webgpu: requestAdapter threw: {e:?}");
return false;
}
};
let promise: js_sys::Promise = match promise.dyn_into() {
Ok(p) => p,
Err(_) => {
log::info!("detect_webgpu: requestAdapter did not return Promise");
return false;
}
};
match wasm_bindgen_futures::JsFuture::from(promise).await {
Ok(adapter) => {
let ok = !adapter.is_null() && !adapter.is_undefined();
log::info!("detect_webgpu: requestAdapter resolved (adapter present = {ok})");
ok
}
Err(e) => {
log::info!("detect_webgpu: requestAdapter rejected: {e:?}");
false
}
}
}
pub fn show_browser_compat_error(detail: &str) {
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
if let Some(body) = doc.body() {
let html = format!(
"<div id=\"compat-error\" style=\"\
position:fixed; inset:0; background:rgba(0,0,0,0.85);\
color:#f0f0f0; padding:40px; font-family:system-ui,sans-serif;\
z-index:1000; overflow:auto;\">\
<h1 style=\"margin-top:0;color:#f88;\">Couldn't start the renderer</h1>\
<p>Your browser couldn't open a WebGL2 or WebGPU context on the canvas.</p>\
<p><strong>Chrome / Chromium / Edge:</strong> open \
<code>chrome://gpu</code> and confirm \"WebGL 2\" says \
<em>Hardware accelerated</em>. If not, enable hardware acceleration in \
<code>chrome://settings/system</code>, or set \
<code>chrome://flags/#ignore-gpu-blocklist</code> to <em>Enabled</em>.\
</p>\
<p><strong>LibreWolf / Firefox:</strong> in <code>about:config</code> set \
<code>webgl.disabled</code> = false, \
<code>webgl.force-enabled</code> = true, \
<code>privacy.resistFingerprinting</code> = false.\
</p>\
<p style=\"opacity:0.65;font-size:12px;\">Underlying error: {detail}</p>\
</div>",
detail = html_escape(detail)
);
let _ = body.insert_adjacent_html("beforeend", &html);
}
}
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
}

237
src/render/pipelines.rs Normal file
View file

@ -0,0 +1,237 @@
//! Pipeline + bind-group-layout factories. Each `*_pipeline` function
//! takes device + shader + surface format and returns a configured
//! `wgpu::RenderPipeline`. The renderer wires them up in `Renderer::new`.
use crate::mesh::Vertex;
use crate::render::uniform::OutlineVertex;
use wgpu::{
BindGroupLayout, ColorTargetState, Device, FragmentState, PipelineLayout, RenderPipeline,
ShaderModule, TextureFormat, VertexState,
};
pub fn camera_bgl(device: &Device) -> BindGroupLayout {
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("camera bgl"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
})
}
pub fn post_bgl(device: &Device) -> BindGroupLayout {
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("post bgl"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
})
}
pub fn pipeline_layout(
device: &Device,
label: &str,
layouts: &[&BindGroupLayout],
) -> PipelineLayout {
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some(label),
bind_group_layouts: layouts,
push_constant_ranges: &[],
})
}
fn color_target(format: TextureFormat) -> ColorTargetState {
ColorTargetState {
format,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
}
}
/// Full-screen sky background. No vertex buffer; the shader emits a
/// covering triangle from `vertex_index`. Depth always passes (drawn
/// before terrain so terrain naturally overwrites it where present).
pub fn sky_pipeline(
device: &Device,
layout: &PipelineLayout,
shader: &ShaderModule,
format: TextureFormat,
) -> RenderPipeline {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("sky pipeline"),
layout: Some(layout),
vertex: VertexState {
module: shader,
entry_point: Some("vs_sky"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(FragmentState {
module: shader,
entry_point: Some("fs_sky"),
targets: &[Some(color_target(format))],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
front_face: wgpu::FrontFace::Ccw,
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: false,
depth_compare: wgpu::CompareFunction::Always,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState::default(),
multiview: None,
cache: None,
})
}
/// Terrain + remote-player meshes. Standard depth-tested back-face-
/// culled triangles consuming `mesh::Vertex` layout.
pub fn terrain_pipeline(
device: &Device,
layout: &PipelineLayout,
shader: &ShaderModule,
format: TextureFormat,
) -> RenderPipeline {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("terrain pipeline"),
layout: Some(layout),
vertex: VertexState {
module: shader,
entry_point: Some("vs_main"),
buffers: &[Vertex::LAYOUT],
compilation_options: Default::default(),
},
fragment: Some(FragmentState {
module: shader,
entry_point: Some("fs_main"),
targets: &[Some(color_target(format))],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
front_face: wgpu::FrontFace::Ccw,
cull_mode: Some(wgpu::Face::Back),
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::Less,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState::default(),
multiview: None,
cache: None,
})
}
/// Targeted block outline — 12 line segments around an AABB. Uses
/// `LessEqual` depth so it draws on top of the face it borders.
pub fn outline_pipeline(
device: &Device,
layout: &PipelineLayout,
shader: &ShaderModule,
format: TextureFormat,
) -> RenderPipeline {
let outline_layout = wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<OutlineVertex>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &wgpu::vertex_attr_array![0 => Float32x3],
};
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("outline pipeline"),
layout: Some(layout),
vertex: VertexState {
module: shader,
entry_point: Some("vs_outline"),
buffers: &[outline_layout],
compilation_options: Default::default(),
},
fragment: Some(FragmentState {
module: shader,
entry_point: Some("fs_outline"),
targets: &[Some(color_target(format))],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::LineList,
front_face: wgpu::FrontFace::Ccw,
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: false,
depth_compare: wgpu::CompareFunction::LessEqual,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState::default(),
multiview: None,
cache: None,
})
}
/// Pass-through post: full-screen triangle that samples `scene_color`
/// back to the surface. Foundation for FXAA / sun shafts / tonemap.
pub fn post_pipeline(
device: &Device,
layout: &PipelineLayout,
shader: &ShaderModule,
format: TextureFormat,
) -> RenderPipeline {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("post pipeline"),
layout: Some(layout),
vertex: VertexState {
module: shader,
entry_point: Some("vs_post"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(FragmentState {
module: shader,
entry_point: Some("fs_post"),
targets: &[Some(color_target(format))],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
front_face: wgpu::FrontFace::Ccw,
cull_mode: None,
..Default::default()
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview: None,
cache: None,
})
}

View file

@ -0,0 +1,72 @@
//! Off-screen render targets: depth buffer, scene-color HDR texture,
//! and the post-pass bind group that samples scene-color back into the
//! surface. All factories take device + dimensions and return a fresh
//! resource — no hidden mutation. Renderer::resize calls these to
//! recreate after a surface change.
use wgpu::{Device, Sampler, TextureFormat, TextureView};
pub fn create_depth_view(device: &Device, w: u32, h: u32) -> TextureView {
let tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("depth"),
size: wgpu::Extent3d {
width: w.max(1),
height: h.max(1),
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
tex.create_view(&wgpu::TextureViewDescriptor::default())
}
/// Off-screen color target the world is rendered into. Same format as
/// the surface so the post pass can sample it and write the result back
/// without any conversion cost.
pub fn create_scene_color_view(
device: &Device,
w: u32,
h: u32,
format: TextureFormat,
) -> TextureView {
let tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("scene color"),
size: wgpu::Extent3d {
width: w.max(1),
height: h.max(1),
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
tex.create_view(&wgpu::TextureViewDescriptor::default())
}
pub fn create_post_bind_group(
device: &Device,
layout: &wgpu::BindGroupLayout,
scene: &TextureView,
sampler: &Sampler,
) -> wgpu::BindGroup {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("post bg"),
layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(scene),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(sampler),
},
],
})
}

42
src/render/uniform.rs Normal file
View file

@ -0,0 +1,42 @@
//! GPU-side uniform + vertex layouts owned by the renderer.
use bytemuck::{Pod, Zeroable};
use glam::Mat4;
/// Uniform pushed to `@group(0) @binding(0)` of the world shader.
/// Layout mirrors WGSL `struct Camera` in `shader.wgsl`.
#[repr(C)]
#[derive(Copy, Clone, Pod, Zeroable)]
pub struct CameraUniform {
pub view_proj: [[f32; 4]; 4],
pub inv_view_proj: [[f32; 4]; 4],
pub eye: [f32; 4],
/// `.x` = scene time in seconds (drives day/night cycle + leaf
/// sway). `.y/.z/.w` reserved. Matches WGSL `camera.frame` with
/// `scene_time()` accessor.
pub frame: [f32; 4],
}
impl CameraUniform {
pub fn identity() -> Self {
Self {
view_proj: Mat4::IDENTITY.to_cols_array_2d(),
inv_view_proj: Mat4::IDENTITY.to_cols_array_2d(),
eye: [0.0; 4],
frame: [0.0; 4],
}
}
}
#[repr(C)]
#[derive(Copy, Clone, Pod, Zeroable)]
pub struct OutlineVertex {
pub pos: [f32; 3],
}
/// Vertex buffer + index buffer + count for a single chunk's mesh.
/// Owned by the Renderer's `chunk_buffers` map.
pub struct ChunkBuffers {
pub vertex: wgpu::Buffer,
pub index: wgpu::Buffer,
pub index_count: u32,
}

View file

@ -1,11 +1,10 @@
//! Imperative shell: owns the GPU resources, the winit window, and the
//! JS bridges. Every tick threads the pure simulation core
//! (`crate::sim`) and pure net parser (`crate::net`) through the
//! world/renderer/network — this file should remain *thin*.
//! Imperative shell: owns the App + JS bridges + winit handler.
//! The Renderer lives in `crate::render`; pure logic in `crate::sim`
//! and `crate::net`. This file's job is to compose them per tick.
use crate::camera::{Camera, InputState, KbHeld};
use crate::mesh::{build_chunk_mesh, emit_oriented_box, name_hash, Vertex};
use crate::net::{parse_inbox, NetEvent};
use crate::proto::{ClientMsg, EditRec};
use crate::render::Renderer;
use crate::sim::collision::{aabb_overlap_player, AabbI, EYE_HEIGHT};
use crate::sim::edit::{apply_edit, block_from_u8, chunks_for_edit};
use crate::sim::input::TouchBridge;
@ -14,14 +13,12 @@ use crate::sim::spawn::{fall_damage, find_safe_spawn};
use crate::sim::visibility::compute_visible_chunks;
use crate::sim::{merge_held, step_movement, PlayerBody, SimEvent};
use crate::world::{Block, World, WORLD_RADIUS};
use bytemuck::{Pod, Zeroable};
use glam::{IVec3, Mat4, Vec3};
use glam::{IVec3, Vec3};
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use std::sync::Arc;
use std::time::Duration;
use wgpu::util::DeviceExt;
use winit::application::ApplicationHandler;
use winit::event::{DeviceEvent, ElementState, MouseButton, WindowEvent};
use winit::event_loop::ActiveEventLoop;
@ -34,32 +31,7 @@ use std::time::Instant;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::JsCast;
#[repr(C)]
#[derive(Copy, Clone, Pod, Zeroable)]
struct CameraUniform {
view_proj: [[f32; 4]; 4],
inv_view_proj: [[f32; 4]; 4],
eye: [f32; 4],
/// `.x` = scene time in seconds (drives day/night cycle + leaf sway).
/// `.y/.z/.w` reserved. Layout mirrored in `shader.wgsl` as
/// `camera.frame` with a `scene_time()` accessor.
frame: [f32; 4],
}
#[repr(C)]
#[derive(Copy, Clone, Pod, Zeroable)]
struct OutlineVertex {
pos: [f32; 3],
}
struct ChunkBuffers {
vertex: wgpu::Buffer,
index: wgpu::Buffer,
index_count: u32,
}
const REACH: f32 = 6.0;
const OUTLINE_VERT_COUNT: u64 = 24;
thread_local! {
/// Storage for the touch/controller bridge. The struct itself lives
@ -251,764 +223,6 @@ mod wasm_api {
}
}
pub struct Renderer {
surface: wgpu::Surface<'static>,
device: wgpu::Device,
queue: wgpu::Queue,
config: wgpu::SurfaceConfiguration,
pipeline: wgpu::RenderPipeline,
sky_pipeline: wgpu::RenderPipeline,
outline_pipeline: wgpu::RenderPipeline,
outline_buffer: wgpu::Buffer,
depth_view: wgpu::TextureView,
camera_buffer: wgpu::Buffer,
camera_bind_group: wgpu::BindGroup,
window: Arc<Window>,
chunk_buffers: HashMap<IVec3, ChunkBuffers>,
outline_target: Option<IVec3>,
visible_chunks: Vec<IVec3>,
remote_vb: wgpu::Buffer,
remote_ib: wgpu::Buffer,
remote_index_count: u32,
// ---- Post processing (Step 1: pass-through scene → surface) ----
scene_color: wgpu::TextureView,
scene_color_format: wgpu::TextureFormat,
post_sampler: wgpu::Sampler,
post_bgl: wgpu::BindGroupLayout,
post_bind_group: wgpu::BindGroup,
post_pipeline: wgpu::RenderPipeline,
}
const MAX_REMOTE_PLAYERS: u64 = 32;
const REMOTE_VERTS_PER_PLAYER: u64 = 2 * 24;
const REMOTE_INDICES_PER_PLAYER: u64 = 2 * 36;
impl Renderer {
pub async fn new(window: Arc<Window>) -> Self {
let size = window.inner_size();
#[allow(unused_mut)]
let (mut width, mut height) = (size.width.max(1), size.height.max(1));
#[cfg(target_arch = "wasm32")]
{
if width <= 2 || height <= 2 {
if let Some(c) = web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.get_element_by_id("game-canvas"))
.and_then(|e| {
wasm_bindgen::JsCast::dyn_into::<web_sys::HtmlCanvasElement>(e).ok()
})
{
let w = c.width().max(1);
let h = c.height().max(1);
log::info!("overriding inner_size {}x{} with canvas {}x{}", width, height, w, h);
width = w;
height = h;
}
}
}
log::info!("initial surface size: {}x{}", width, height);
#[cfg(target_arch = "wasm32")]
let backends = {
let webgpu_ok = detect_webgpu().await;
if webgpu_ok {
log::info!("WebGPU adapter probe OK — using WebGPU backend");
wgpu::Backends::BROWSER_WEBGPU
} else {
log::info!("WebGPU unavailable — using WebGL2 backend");
wgpu::Backends::GL
}
};
#[cfg(not(target_arch = "wasm32"))]
let backends = wgpu::Backends::PRIMARY | wgpu::Backends::GL;
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
backends,
..Default::default()
});
let surface = match instance.create_surface(window.clone()) {
Ok(s) => s,
Err(e) => {
log::error!("create_surface failed: {e:?}");
#[cfg(target_arch = "wasm32")]
show_browser_compat_error(&format!("{e}"));
panic!("could not create rendering surface: {e:?}");
}
};
let adapter = match instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: Some(&surface),
force_fallback_adapter: false,
})
.await
{
Some(a) => a,
None => {
#[cfg(target_arch = "wasm32")]
show_browser_compat_error(
"No GPU adapter available. WebGL2/WebGPU may be disabled in your browser.",
);
panic!("no suitable GPU adapter");
}
};
let info = adapter.get_info();
log::info!(
"wgpu adapter: backend={:?} type={:?} name={:?}",
info.backend,
info.device_type,
info.name,
);
let primary_limits = if matches!(info.backend, wgpu::Backend::BrowserWebGpu) {
wgpu::Limits::default()
} else {
wgpu::Limits::downlevel_webgl2_defaults()
};
let device_desc = wgpu::DeviceDescriptor {
label: Some("device"),
required_features: wgpu::Features::empty(),
required_limits: primary_limits,
memory_hints: wgpu::MemoryHints::Performance,
};
let (device, queue) = match adapter.request_device(&device_desc, None).await {
Ok(dq) => dq,
Err(e) => {
log::warn!("device request failed ({e:?}); retrying with downlevel limits");
let fallback = wgpu::DeviceDescriptor {
label: Some("device-fallback"),
required_features: wgpu::Features::empty(),
required_limits: wgpu::Limits::downlevel_webgl2_defaults(),
memory_hints: wgpu::MemoryHints::Performance,
};
adapter
.request_device(&fallback, None)
.await
.expect("device request failed even with downlevel limits")
}
};
let caps = surface.get_capabilities(&adapter);
let format = caps
.formats
.iter()
.copied()
.find(|f| f.is_srgb())
.unwrap_or(caps.formats[0]);
let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format,
width,
height,
present_mode: wgpu::PresentMode::Fifo,
alpha_mode: caps.alpha_modes[0],
view_formats: vec![],
desired_maximum_frame_latency: 2,
};
surface.configure(&device, &config);
let depth_view = create_depth_view(&device, width, height);
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("shader"),
source: wgpu::ShaderSource::Wgsl(
crate::shader_source::terrain_shader_source().into(),
),
});
let camera_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("camera"),
contents: bytemuck::bytes_of(&CameraUniform {
view_proj: Mat4::IDENTITY.to_cols_array_2d(),
inv_view_proj: Mat4::IDENTITY.to_cols_array_2d(),
eye: [0.0; 4],
frame: [0.0; 4],
}),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let camera_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("camera bgl"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let camera_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("camera bg"),
layout: &camera_bgl,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: camera_buffer.as_entire_binding(),
}],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("pl"),
bind_group_layouts: &[&camera_bgl],
push_constant_ranges: &[],
});
let sky_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("sky pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_sky"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_sky"),
targets: &[Some(wgpu::ColorTargetState {
format: config.format,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
cull_mode: None,
polygon_mode: wgpu::PolygonMode::Fill,
unclipped_depth: false,
conservative: false,
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: false,
depth_compare: wgpu::CompareFunction::Always,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState::default(),
multiview: None,
cache: None,
});
let outline_vertex_layout = wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<OutlineVertex>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &wgpu::vertex_attr_array![0 => Float32x3],
};
let outline_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("outline buf"),
size: OUTLINE_VERT_COUNT * std::mem::size_of::<OutlineVertex>() as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let remote_vb = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("remote vb"),
size: MAX_REMOTE_PLAYERS * REMOTE_VERTS_PER_PLAYER
* std::mem::size_of::<Vertex>() as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let remote_ib = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("remote ib"),
size: MAX_REMOTE_PLAYERS * REMOTE_INDICES_PER_PLAYER * 4,
usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let outline_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("outline pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_outline"),
buffers: &[outline_vertex_layout],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_outline"),
targets: &[Some(wgpu::ColorTargetState {
format: config.format,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::LineList,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
cull_mode: None,
polygon_mode: wgpu::PolygonMode::Fill,
unclipped_depth: false,
conservative: false,
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: false,
depth_compare: wgpu::CompareFunction::LessEqual,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState::default(),
multiview: None,
cache: None,
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[Vertex::LAYOUT],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format: config.format,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
cull_mode: Some(wgpu::Face::Back),
polygon_mode: wgpu::PolygonMode::Fill,
unclipped_depth: false,
conservative: false,
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::Less,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState::default(),
multiview: None,
cache: None,
});
// ---------- Post pipeline (Step 1: pass-through) ----------
let scene_color_format = config.format;
let scene_color = create_scene_color_view(&device, width, height, scene_color_format);
let post_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("post sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
address_mode_w: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::FilterMode::Nearest,
..Default::default()
});
let post_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("post bgl"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let post_bind_group = create_post_bg(&device, &post_bgl, &scene_color, &post_sampler);
let post_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("post shader"),
source: wgpu::ShaderSource::Wgsl(crate::shader_source::post_shader_source().into()),
});
let post_pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("post pl"),
bind_group_layouts: &[&post_bgl],
push_constant_ranges: &[],
});
let post_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("post pipeline"),
layout: Some(&post_pl),
vertex: wgpu::VertexState {
module: &post_shader,
entry_point: Some("vs_post"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &post_shader,
entry_point: Some("fs_post"),
targets: &[Some(wgpu::ColorTargetState {
format: config.format,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
cull_mode: None,
polygon_mode: wgpu::PolygonMode::Fill,
unclipped_depth: false,
conservative: false,
},
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview: None,
cache: None,
});
Self {
surface,
device,
queue,
config,
pipeline,
sky_pipeline,
outline_pipeline,
outline_buffer,
depth_view,
camera_buffer,
camera_bind_group,
window,
chunk_buffers: HashMap::new(),
outline_target: None,
visible_chunks: Vec::new(),
remote_vb,
remote_ib,
remote_index_count: 0,
scene_color,
scene_color_format,
post_sampler,
post_bgl,
post_bind_group,
post_pipeline,
}
}
pub fn set_remote_players(&mut self, players: &[RemotePlayer]) {
if players.is_empty() {
self.remote_index_count = 0;
return;
}
let mut verts: Vec<Vertex> = Vec::with_capacity(players.len() * 48);
let mut indices: Vec<u32> = Vec::with_capacity(players.len() * 72);
let max = players.len().min(MAX_REMOTE_PLAYERS as usize);
for p in &players[..max] {
let h = name_hash(&p.name);
let r = 0.35 + (h & 0x3F) as f32 / 255.0;
let g = 0.35 + ((h >> 8) & 0x3F) as f32 / 255.0;
let b = 0.35 + ((h >> 16) & 0x3F) as f32 / 255.0;
let body = [r, g, b];
let head = [r * 0.85 + 0.15, g * 0.85 + 0.15, b * 0.85 + 0.15];
emit_oriented_box(
Vec3::new(p.pos.x, p.pos.y + 0.65, p.pos.z),
Vec3::new(0.3, 0.65, 0.2),
p.yaw,
body,
&mut verts,
&mut indices,
);
emit_oriented_box(
Vec3::new(p.pos.x, p.pos.y + 1.55, p.pos.z),
Vec3::new(0.25, 0.25, 0.25),
p.yaw,
head,
&mut verts,
&mut indices,
);
}
self.remote_index_count = indices.len() as u32;
self.queue
.write_buffer(&self.remote_vb, 0, bytemuck::cast_slice(&verts));
self.queue
.write_buffer(&self.remote_ib, 0, bytemuck::cast_slice(&indices));
}
pub fn set_outline(&mut self, target: Option<IVec3>) {
if self.outline_target == target {
return;
}
self.outline_target = target;
if let Some(b) = target {
let eps = 0.002;
let min = [b.x as f32 - eps, b.y as f32 - eps, b.z as f32 - eps];
let max = [
b.x as f32 + 1.0 + eps,
b.y as f32 + 1.0 + eps,
b.z as f32 + 1.0 + eps,
];
let c000 = [min[0], min[1], min[2]];
let c100 = [max[0], min[1], min[2]];
let c001 = [min[0], min[1], max[2]];
let c101 = [max[0], min[1], max[2]];
let c010 = [min[0], max[1], min[2]];
let c110 = [max[0], max[1], min[2]];
let c011 = [min[0], max[1], max[2]];
let c111 = [max[0], max[1], max[2]];
let verts = [
c000, c100, c100, c101, c101, c001, c001, c000, c010, c110, c110, c111, c111,
c011, c011, c010, c000, c010, c100, c110, c101, c111, c001, c011,
];
let data: Vec<OutlineVertex> = verts.iter().map(|p| OutlineVertex { pos: *p }).collect();
self.queue
.write_buffer(&self.outline_buffer, 0, bytemuck::cast_slice(&data));
}
}
pub fn resize(&mut self, width: u32, height: u32) {
if width == 0 || height == 0 {
return;
}
self.config.width = width;
self.config.height = height;
self.surface.configure(&self.device, &self.config);
self.depth_view = create_depth_view(&self.device, width, height);
self.scene_color =
create_scene_color_view(&self.device, width, height, self.scene_color_format);
self.post_bind_group =
create_post_bg(&self.device, &self.post_bgl, &self.scene_color, &self.post_sampler);
}
pub fn rebuild_chunk(&mut self, coord: IVec3, world: &World) {
let Some(chunk) = world.chunks.get(&coord) else {
return;
};
let mesh = build_chunk_mesh(world, chunk);
if mesh.indices.is_empty() {
self.chunk_buffers.remove(&coord);
return;
}
let vertex = self
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("chunk vb"),
contents: bytemuck::cast_slice(&mesh.vertices),
usage: wgpu::BufferUsages::VERTEX,
});
let index = self
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("chunk ib"),
contents: bytemuck::cast_slice(&mesh.indices),
usage: wgpu::BufferUsages::INDEX,
});
self.chunk_buffers.insert(
coord,
ChunkBuffers {
vertex,
index,
index_count: mesh.indices.len() as u32,
},
);
}
pub fn upload_camera(&self, camera: &Camera, time: f32) {
let vp = camera.view_proj();
let inv = vp.inverse();
let uni = CameraUniform {
view_proj: vp.to_cols_array_2d(),
inv_view_proj: inv.to_cols_array_2d(),
eye: [camera.position.x, camera.position.y, camera.position.z, 1.0],
frame: [time, 0.0, 0.0, 0.0],
};
self.queue
.write_buffer(&self.camera_buffer, 0, bytemuck::bytes_of(&uni));
}
pub fn set_visible(&mut self, chunks: Vec<IVec3>) {
self.visible_chunks = chunks;
}
pub fn render(&self) -> Result<(), wgpu::SurfaceError> {
let frame = self.surface.get_current_texture()?;
let surface_view = frame
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("enc") });
// ---- Scene pass: render world into the offscreen scene_color. ----
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("scene pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.scene_color,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: 0.30,
g: 0.55,
b: 0.88,
a: 1.0,
}),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_pipeline(&self.sky_pipeline);
pass.set_bind_group(0, &self.camera_bind_group, &[]);
pass.draw(0..3, 0..1);
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &self.camera_bind_group, &[]);
let iter: Box<dyn Iterator<Item = &ChunkBuffers>> = if self.visible_chunks.is_empty() {
Box::new(self.chunk_buffers.values())
} else {
Box::new(self.visible_chunks.iter().filter_map(|c| self.chunk_buffers.get(c)))
};
for buf in iter {
pass.set_vertex_buffer(0, buf.vertex.slice(..));
pass.set_index_buffer(buf.index.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(0..buf.index_count, 0, 0..1);
}
if self.remote_index_count > 0 {
pass.set_vertex_buffer(0, self.remote_vb.slice(..));
pass.set_index_buffer(self.remote_ib.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(0..self.remote_index_count, 0, 0..1);
}
if self.outline_target.is_some() {
pass.set_pipeline(&self.outline_pipeline);
pass.set_bind_group(0, &self.camera_bind_group, &[]);
pass.set_vertex_buffer(0, self.outline_buffer.slice(..));
pass.draw(0..OUTLINE_VERT_COUNT as u32, 0..1);
}
}
// ---- Post pass: copy scene_color to surface (effects later). ----
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("post pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &surface_view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_pipeline(&self.post_pipeline);
pass.set_bind_group(0, &self.post_bind_group, &[]);
pass.draw(0..3, 0..1);
}
self.queue.submit(Some(encoder.finish()));
frame.present();
Ok(())
}
}
fn create_depth_view(device: &wgpu::Device, w: u32, h: u32) -> wgpu::TextureView {
let tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("depth"),
size: wgpu::Extent3d {
width: w.max(1),
height: h.max(1),
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
tex.create_view(&wgpu::TextureViewDescriptor::default())
}
fn create_scene_color_view(
device: &wgpu::Device,
w: u32,
h: u32,
format: wgpu::TextureFormat,
) -> wgpu::TextureView {
let tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("scene color"),
size: wgpu::Extent3d {
width: w.max(1),
height: h.max(1),
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
tex.create_view(&wgpu::TextureViewDescriptor::default())
}
fn create_post_bg(
device: &wgpu::Device,
layout: &wgpu::BindGroupLayout,
scene: &wgpu::TextureView,
sampler: &wgpu::Sampler,
) -> wgpu::BindGroup {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("post bg"),
layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(scene),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(sampler),
},
],
})
}
#[derive(Default)]
pub struct App {
@ -1654,97 +868,3 @@ impl App {
}
}
#[cfg(target_arch = "wasm32")]
async fn detect_webgpu() -> bool {
use wasm_bindgen::{JsCast, JsValue};
let Some(win) = web_sys::window() else {
log::info!("detect_webgpu: no window");
return false;
};
let nav: JsValue = win.navigator().into();
let gpu = match js_sys::Reflect::get(&nav, &JsValue::from_str("gpu")) {
Ok(v) if !v.is_undefined() && !v.is_null() => v,
_ => {
log::info!("detect_webgpu: navigator.gpu missing");
return false;
}
};
let req = match js_sys::Reflect::get(&gpu, &JsValue::from_str("requestAdapter")) {
Ok(v) => v,
Err(_) => {
log::info!("detect_webgpu: requestAdapter prop missing");
return false;
}
};
let req_fn: js_sys::Function = match req.dyn_into() {
Ok(f) => f,
Err(_) => {
log::info!("detect_webgpu: requestAdapter not callable");
return false;
}
};
let promise = match req_fn.call0(&gpu) {
Ok(p) => p,
Err(e) => {
log::info!("detect_webgpu: requestAdapter threw: {e:?}");
return false;
}
};
let promise: js_sys::Promise = match promise.dyn_into() {
Ok(p) => p,
Err(_) => {
log::info!("detect_webgpu: requestAdapter did not return Promise");
return false;
}
};
match wasm_bindgen_futures::JsFuture::from(promise).await {
Ok(adapter) => {
let ok = !adapter.is_null() && !adapter.is_undefined();
log::info!("detect_webgpu: requestAdapter resolved (adapter present = {ok})");
ok
}
Err(e) => {
log::info!("detect_webgpu: requestAdapter rejected: {e:?}");
false
}
}
}
#[cfg(target_arch = "wasm32")]
fn show_browser_compat_error(detail: &str) {
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
if let Some(body) = doc.body() {
let html = format!(
"<div id=\"compat-error\" style=\"\
position:fixed; inset:0; background:rgba(0,0,0,0.85);\
color:#f0f0f0; padding:40px; font-family:system-ui,sans-serif;\
z-index:1000; overflow:auto;\">\
<h1 style=\"margin-top:0;color:#f88;\">Couldn't start the renderer</h1>\
<p>Your browser couldn't open a WebGL2 or WebGPU context on the canvas.</p>\
<p><strong>Chrome / Chromium / Edge:</strong> open \
<code>chrome://gpu</code> and confirm \"WebGL 2\" says \
<em>Hardware accelerated</em>. If not, enable hardware acceleration in \
<code>chrome://settings/system</code>, or set \
<code>chrome://flags/#ignore-gpu-blocklist</code> to <em>Enabled</em>.\
</p>\
<p><strong>LibreWolf / Firefox:</strong> in <code>about:config</code> set \
<code>webgl.disabled</code> = false, \
<code>webgl.force-enabled</code> = true, \
<code>privacy.resistFingerprinting</code> = false.\
</p>\
<p style=\"opacity:0.65;font-size:12px;\">Underlying error: {detail}</p>\
</div>",
detail = html_escape(detail)
);
let _ = body.insert_adjacent_html("beforeend", &html);
}
}
}
#[cfg(target_arch = "wasm32")]
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}