Compare commits
4 commits
c6f50bcb50
...
55276b7ce0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55276b7ce0 | ||
|
|
549662ddc8 | ||
|
|
accbf67bf2 | ||
|
|
989de4f43d |
14 changed files with 2561 additions and 2058 deletions
637
src/app.rs
Normal file
637
src/app.rs
Normal file
|
|
@ -0,0 +1,637 @@
|
|||
//! The App — winit `ApplicationHandler` plus the tick loop. This is
|
||||
//! the orchestrator that threads pure simulation (`sim`), pure net
|
||||
//! parsing (`net`), and the GPU shell (`render`) together every frame.
|
||||
//!
|
||||
//! The tick is a pipeline:
|
||||
//!
|
||||
//! 1. Compute dt + scaled shader time
|
||||
//! 2. (paused?) clear inputs + render last frame + return
|
||||
//! 3. Drain touch bridge → per-tick input
|
||||
//! 4. Honor respawn-request one-shot
|
||||
//! 5. Drain net inbox → parsed events → fold into world / remote map
|
||||
//! 6. Send Hello on (re)connection
|
||||
//! 7. Apply mouse look to camera
|
||||
//! 8. step_movement(world, body, MoveInput) → (body', events)
|
||||
//! 9. Fold sim events into damage
|
||||
//! 10. Sync camera position with body
|
||||
//! 11. Block interaction (raycast → break/place) → maybe broadcast edit
|
||||
//! 12. Apply damage; periodic state broadcast
|
||||
//! 13. Render frame
|
||||
use crate::bridges::{self, RemotePlayer, Settings};
|
||||
use crate::camera::{Camera, InputState, KbHeld};
|
||||
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::physics::MoveInput;
|
||||
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 glam::{IVec3, Vec3};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use winit::application::ApplicationHandler;
|
||||
use winit::event::{DeviceEvent, ElementState, MouseButton, WindowEvent};
|
||||
use winit::event_loop::ActiveEventLoop;
|
||||
use winit::keyboard::{KeyCode, PhysicalKey};
|
||||
use winit::window::{Window, WindowId};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::time::Instant;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
const REACH: f32 = 6.0;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct App {
|
||||
window: Option<Arc<Window>>,
|
||||
renderer: Rc<RefCell<Option<Renderer>>>,
|
||||
world: Rc<RefCell<Option<World>>>,
|
||||
camera: Rc<RefCell<Option<Camera>>>,
|
||||
input: InputState,
|
||||
keyboard: KbHeld,
|
||||
body: PlayerBody,
|
||||
last_frame: Option<FrameClock>,
|
||||
start_clock: Option<FrameClock>,
|
||||
pointer_locked: bool,
|
||||
last_net_send: f32,
|
||||
was_connected: bool,
|
||||
/// Accumulated *scaled* time. Real dt × settings.time_scale per
|
||||
/// tick — feeds `camera.frame.x` so the shader's day/night cycle
|
||||
/// slows / freezes / fast-forwards by the player's setting.
|
||||
shader_time: f32,
|
||||
}
|
||||
|
||||
impl Default for PlayerBody {
|
||||
fn default() -> Self {
|
||||
PlayerBody::spawned_at(Vec3::ZERO)
|
||||
}
|
||||
}
|
||||
|
||||
struct FrameClock {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
instant: Instant,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
millis: f64,
|
||||
}
|
||||
|
||||
impl FrameClock {
|
||||
fn now() -> Self {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
Self {
|
||||
instant: Instant::now(),
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let perf = web_sys::window().unwrap().performance().unwrap();
|
||||
Self { millis: perf.now() }
|
||||
}
|
||||
}
|
||||
|
||||
fn elapsed(&self) -> Duration {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
self.instant.elapsed()
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let perf = web_sys::window().unwrap().performance().unwrap();
|
||||
let dt_ms = (perf.now() - self.millis).max(0.0);
|
||||
Duration::from_secs_f64(dt_ms / 1000.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ApplicationHandler for App {
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if self.window.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
#[allow(unused_mut)]
|
||||
let mut attrs = Window::default_attributes().with_title("Voxel Game");
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
use winit::platform::web::WindowAttributesExtWebSys;
|
||||
let canvas = web_sys::window()
|
||||
.and_then(|w| w.document())
|
||||
.and_then(|d| d.get_element_by_id("game-canvas"))
|
||||
.and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok());
|
||||
if let Some(c) = canvas {
|
||||
let dpr = web_sys::window()
|
||||
.map(|w| w.device_pixel_ratio())
|
||||
.unwrap_or(1.0)
|
||||
.max(1.0);
|
||||
let rect = c.get_bounding_client_rect();
|
||||
let css_w = if rect.width() > 0.0 {
|
||||
rect.width()
|
||||
} else {
|
||||
web_sys::window()
|
||||
.and_then(|w| w.inner_width().ok())
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(1280.0)
|
||||
};
|
||||
let css_h = if rect.height() > 0.0 {
|
||||
rect.height()
|
||||
} else {
|
||||
web_sys::window()
|
||||
.and_then(|w| w.inner_height().ok())
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(720.0)
|
||||
};
|
||||
let pw = (css_w * dpr).max(1.0) as u32;
|
||||
let ph = (css_h * dpr).max(1.0) as u32;
|
||||
c.set_width(pw);
|
||||
c.set_height(ph);
|
||||
log::info!("canvas sized to {}x{} (css {}x{} dpr {})", pw, ph, css_w, css_h, dpr);
|
||||
attrs = attrs.with_canvas(Some(c));
|
||||
}
|
||||
}
|
||||
|
||||
let window = Arc::new(event_loop.create_window(attrs).unwrap());
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
if let Some(win) = web_sys::window() {
|
||||
let dpr = win.device_pixel_ratio().max(1.0);
|
||||
let w = win.inner_width().ok().and_then(|v| v.as_f64()).unwrap_or(1280.0);
|
||||
let h = win.inner_height().ok().and_then(|v| v.as_f64()).unwrap_or(720.0);
|
||||
let _ = window.request_inner_size(winit::dpi::PhysicalSize::new(
|
||||
(w * dpr) as u32,
|
||||
(h * dpr) as u32,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
self.window = Some(window.clone());
|
||||
|
||||
let world = World::new();
|
||||
let aspect = {
|
||||
let s = window.inner_size();
|
||||
s.width as f32 / s.height.max(1) as f32
|
||||
};
|
||||
let spawn = find_safe_spawn(&world);
|
||||
let mut camera = Camera::new(aspect);
|
||||
camera.position = Vec3::new(spawn.x, spawn.y + EYE_HEIGHT, spawn.z);
|
||||
self.body = PlayerBody::spawned_at(spawn);
|
||||
*self.camera.borrow_mut() = Some(camera);
|
||||
*self.world.borrow_mut() = Some(world);
|
||||
|
||||
let renderer_slot = self.renderer.clone();
|
||||
let world_slot = self.world.clone();
|
||||
let window_for_async = window.clone();
|
||||
|
||||
let init = async move {
|
||||
let mut renderer = Renderer::new(window_for_async).await;
|
||||
if let Some(w) = world_slot.borrow().as_ref() {
|
||||
let coords: Vec<IVec3> = w.chunks.keys().copied().collect();
|
||||
for c in coords {
|
||||
renderer.rebuild_chunk(c, w);
|
||||
}
|
||||
}
|
||||
*renderer_slot.borrow_mut() = Some(renderer);
|
||||
};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
pollster::block_on(init);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
wasm_bindgen_futures::spawn_local(init);
|
||||
}
|
||||
|
||||
self.last_frame = Some(FrameClock::now());
|
||||
self.start_clock = Some(FrameClock::now());
|
||||
self.input.selected_block = Block::Stone as u8;
|
||||
bridges::set_status(self.body.hp, self.body.alive);
|
||||
window.request_redraw();
|
||||
}
|
||||
|
||||
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
|
||||
match event {
|
||||
WindowEvent::CloseRequested => event_loop.exit(),
|
||||
WindowEvent::Resized(size) => {
|
||||
if let Some(r) = self.renderer.borrow_mut().as_mut() {
|
||||
r.resize(size.width, size.height);
|
||||
}
|
||||
if let Some(cam) = self.camera.borrow_mut().as_mut() {
|
||||
cam.aspect = size.width as f32 / size.height.max(1) as f32;
|
||||
}
|
||||
}
|
||||
WindowEvent::KeyboardInput { event, .. } => {
|
||||
let pressed = event.state == ElementState::Pressed;
|
||||
if let PhysicalKey::Code(code) = event.physical_key {
|
||||
match code {
|
||||
KeyCode::KeyW => self.keyboard.forward = pressed,
|
||||
KeyCode::KeyS => self.keyboard.back = pressed,
|
||||
KeyCode::KeyA => self.keyboard.left = pressed,
|
||||
KeyCode::KeyD => self.keyboard.right = pressed,
|
||||
KeyCode::Space => self.keyboard.up = pressed,
|
||||
KeyCode::ShiftLeft | KeyCode::ShiftRight => self.keyboard.down = pressed,
|
||||
KeyCode::ControlLeft | KeyCode::ControlRight => {
|
||||
self.keyboard.sprint = pressed;
|
||||
}
|
||||
KeyCode::Digit1 if pressed => self.input.selected_block = Block::Grass as u8,
|
||||
KeyCode::Digit2 if pressed => self.input.selected_block = Block::Dirt as u8,
|
||||
KeyCode::Digit3 if pressed => self.input.selected_block = Block::Stone as u8,
|
||||
KeyCode::Digit4 if pressed => self.input.selected_block = Block::Sand as u8,
|
||||
KeyCode::Digit5 if pressed => self.input.selected_block = Block::Wood as u8,
|
||||
KeyCode::Digit6 if pressed => self.input.selected_block = Block::Leaves as u8,
|
||||
KeyCode::Digit7 if pressed => self.input.selected_block = Block::Cobble as u8,
|
||||
KeyCode::Digit8 if pressed => self.input.selected_block = Block::Brick as u8,
|
||||
KeyCode::Digit9 if pressed => self.input.selected_block = Block::Snow as u8,
|
||||
KeyCode::Digit0 if pressed => self.input.selected_block = Block::Ice as u8,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseInput { state, button, .. } => {
|
||||
if state == ElementState::Pressed {
|
||||
match button {
|
||||
MouseButton::Left => self.input.primary_clicked = true,
|
||||
MouseButton::Right => self.input.secondary_clicked = true,
|
||||
_ => {}
|
||||
}
|
||||
if !bridges::is_touch_mode() {
|
||||
if let Some(w) = &self.window {
|
||||
let _ = w.set_cursor_grab(winit::window::CursorGrabMode::Locked);
|
||||
w.set_cursor_visible(false);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
if let Some(canvas) = web_sys::window()
|
||||
.and_then(|w| w.document())
|
||||
.and_then(|d| d.get_element_by_id("game-canvas"))
|
||||
{
|
||||
let _ = canvas.request_pointer_lock();
|
||||
}
|
||||
}
|
||||
self.pointer_locked = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
WindowEvent::RedrawRequested => {
|
||||
self.tick();
|
||||
if let Some(w) = &self.window {
|
||||
w.request_redraw();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn device_event(
|
||||
&mut self,
|
||||
_event_loop: &ActiveEventLoop,
|
||||
_device_id: winit::event::DeviceId,
|
||||
event: DeviceEvent,
|
||||
) {
|
||||
if let DeviceEvent::MouseMotion { delta } = event {
|
||||
if self.pointer_locked {
|
||||
self.input.mouse_dx += delta.0 as f32;
|
||||
self.input.mouse_dy += delta.1 as f32;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
|
||||
if let Some(w) = &self.window {
|
||||
w.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn do_respawn(&mut self) {
|
||||
let feet = self
|
||||
.world
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.map(find_safe_spawn)
|
||||
.unwrap_or(Vec3::new(0.5, 60.0, 0.5));
|
||||
self.body = PlayerBody::respawned_at(feet);
|
||||
if let Some(cam) = self.camera.borrow_mut().as_mut() {
|
||||
cam.position = Vec3::new(feet.x, feet.y + EYE_HEIGHT, feet.z);
|
||||
}
|
||||
bridges::set_status(self.body.hp, self.body.alive);
|
||||
}
|
||||
|
||||
/// Drain the JS-fed inbox, parse via `net::parse_inbox` into typed
|
||||
/// events, then fold each one into the world / remote-player map.
|
||||
fn drain_net_inbox(&mut self) {
|
||||
let inbox = bridges::take_inbox();
|
||||
if inbox.is_empty() {
|
||||
return;
|
||||
}
|
||||
let events = parse_inbox(inbox);
|
||||
|
||||
let mut world_borrow = self.world.borrow_mut();
|
||||
let Some(world) = world_borrow.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let mut dirty: std::collections::HashSet<IVec3> = std::collections::HashSet::new();
|
||||
for ev in events {
|
||||
match ev {
|
||||
NetEvent::Welcome { id, edits } => {
|
||||
bridges::set_my_id(id);
|
||||
for e in edits {
|
||||
if apply_edit(world, &e) {
|
||||
for c in chunks_for_edit(IVec3::new(e.x, e.y, e.z)) {
|
||||
dirty.insert(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
NetEvent::PlayerList(list) => {
|
||||
bridges::with_net_bridge(|n| {
|
||||
let my = n.my_id;
|
||||
n.remote_players.clear();
|
||||
for p in list {
|
||||
if Some(p.id) == my {
|
||||
continue;
|
||||
}
|
||||
n.remote_players.insert(
|
||||
p.id,
|
||||
RemotePlayer {
|
||||
name: p.name,
|
||||
pos: Vec3::new(p.x, p.y, p.z),
|
||||
yaw: p.yaw,
|
||||
pitch: p.pitch,
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
NetEvent::Edit(rec) => {
|
||||
if apply_edit(world, &rec) {
|
||||
for c in chunks_for_edit(IVec3::new(rec.x, rec.y, rec.z)) {
|
||||
dirty.insert(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
NetEvent::Leave { id } => {
|
||||
bridges::with_net_bridge(|n| {
|
||||
n.remote_players.remove(&id);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if !dirty.is_empty() {
|
||||
if let Some(r) = self.renderer.borrow_mut().as_mut() {
|
||||
for c in dirty {
|
||||
r.rebuild_chunk(c, world);
|
||||
if let Some(ch) = world.chunks.get_mut(&c) {
|
||||
ch.dirty = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame. See module doc-comment for the pipeline shape.
|
||||
fn tick(&mut self) {
|
||||
let dt = match self.last_frame.as_ref() {
|
||||
Some(c) => c.elapsed().as_secs_f32().min(0.1),
|
||||
None => 0.016,
|
||||
};
|
||||
self.last_frame = Some(FrameClock::now());
|
||||
let real_time = self
|
||||
.start_clock
|
||||
.as_ref()
|
||||
.map(|c| c.elapsed().as_secs_f32())
|
||||
.unwrap_or(0.0);
|
||||
let settings = bridges::current_settings();
|
||||
self.shader_time += dt * settings.time_scale;
|
||||
|
||||
// Paused: clear inputs, render the last frame, return.
|
||||
if settings.paused {
|
||||
self.keyboard = KbHeld::default();
|
||||
self.input.mouse_dx = 0.0;
|
||||
self.input.mouse_dy = 0.0;
|
||||
self.input.primary_clicked = false;
|
||||
self.input.secondary_clicked = false;
|
||||
bridges::clear_touch_inputs();
|
||||
self.drain_net_inbox();
|
||||
self.render_frame(settings, None);
|
||||
return;
|
||||
}
|
||||
|
||||
// Drain the touch bridge into the per-tick one-shots.
|
||||
let bridge = bridges::with_touch_bridge(|br| {
|
||||
self.input.mouse_dx += br.look_dx;
|
||||
self.input.mouse_dy += br.look_dy;
|
||||
br.look_dx = 0.0;
|
||||
br.look_dy = 0.0;
|
||||
if br.break_pressed {
|
||||
self.input.primary_clicked = true;
|
||||
br.break_pressed = false;
|
||||
}
|
||||
if br.place_pressed {
|
||||
self.input.secondary_clicked = true;
|
||||
br.place_pressed = false;
|
||||
}
|
||||
if let Some(sel) = br.selected.take() {
|
||||
self.input.selected_block = sel;
|
||||
}
|
||||
br.clone()
|
||||
});
|
||||
|
||||
if bridges::take_respawn_request() {
|
||||
self.do_respawn();
|
||||
}
|
||||
|
||||
self.drain_net_inbox();
|
||||
|
||||
// Hello on (re)connection.
|
||||
let connected = bridges::is_connected();
|
||||
if connected && !self.was_connected {
|
||||
let (_, pending_name) = bridges::net_connection_snapshot();
|
||||
let name = pending_name
|
||||
.unwrap_or_else(|| format!("guest-{}", (real_time * 1000.0) as u32 % 10000));
|
||||
let msg = ClientMsg::Hello { name };
|
||||
if let Ok(s) = serde_json::to_string(&msg) {
|
||||
bridges::push_outbox(s);
|
||||
}
|
||||
}
|
||||
self.was_connected = connected;
|
||||
|
||||
// Mouse look → camera yaw/pitch.
|
||||
let (mx, my) = self.input.consume_mouse();
|
||||
let mut camera_borrow = self.camera.borrow_mut();
|
||||
let Some(camera) = camera_borrow.as_mut() else {
|
||||
return;
|
||||
};
|
||||
camera.yaw += mx * settings.mouse_sens;
|
||||
camera.pitch -= my * settings.mouse_sens;
|
||||
let limit = std::f32::consts::FRAC_PI_2 - 0.01;
|
||||
camera.pitch = camera.pitch.clamp(-limit, limit);
|
||||
camera.fovy = settings.fov_deg.to_radians();
|
||||
|
||||
let mut world_borrow = self.world.borrow_mut();
|
||||
let Some(world) = world_borrow.as_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Pure physics step.
|
||||
let held = merge_held(&self.keyboard, &bridge);
|
||||
let outcome = step_movement(
|
||||
world,
|
||||
self.body,
|
||||
MoveInput {
|
||||
held,
|
||||
forward_flat: camera.forward_flat(),
|
||||
right_flat: camera.right_flat(),
|
||||
dt,
|
||||
},
|
||||
);
|
||||
self.body = outcome.body;
|
||||
|
||||
// Fold sim events into damage (edits come from block interaction below).
|
||||
let mut total_damage: u8 = 0;
|
||||
for e in outcome.events {
|
||||
match e {
|
||||
SimEvent::Landed { fall_dist } => {
|
||||
total_damage = total_damage.saturating_add(fall_damage(fall_dist));
|
||||
}
|
||||
SimEvent::VoidDeath => {
|
||||
total_damage = 20;
|
||||
}
|
||||
SimEvent::BlockEdited { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync the camera position with the body's feet + eye height.
|
||||
camera.position = Vec3::new(
|
||||
self.body.feet.x,
|
||||
self.body.feet.y + EYE_HEIGHT,
|
||||
self.body.feet.z,
|
||||
);
|
||||
|
||||
// Block interaction (raycast → break / place).
|
||||
let hit = world.raycast(camera.position, camera.forward(), REACH);
|
||||
let primary = std::mem::replace(&mut self.input.primary_clicked, false);
|
||||
let secondary = std::mem::replace(&mut self.input.secondary_clicked, false);
|
||||
let mut broadcast_edit: Option<EditRec> = None;
|
||||
|
||||
if self.body.alive {
|
||||
if let Some((hit_pos, prev_pos)) = hit {
|
||||
if primary || secondary {
|
||||
let (target, set_to) = if primary {
|
||||
(hit_pos, Block::Air)
|
||||
} else {
|
||||
(prev_pos, block_from_u8(self.input.selected_block))
|
||||
};
|
||||
let blocks_player = set_to.solid()
|
||||
&& aabb_overlap_player(AabbI::block(target), self.body.feet);
|
||||
if !blocks_player && world.set_block(target, set_to) {
|
||||
let dirty: Vec<IVec3> = world
|
||||
.chunks
|
||||
.iter()
|
||||
.filter(|(_, c)| c.dirty)
|
||||
.map(|(k, _)| *k)
|
||||
.collect();
|
||||
if let Some(r) = self.renderer.borrow_mut().as_mut() {
|
||||
for coord in dirty {
|
||||
r.rebuild_chunk(coord, world);
|
||||
if let Some(c) = world.chunks.get_mut(&coord) {
|
||||
c.dirty = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
broadcast_edit = Some(EditRec {
|
||||
x: target.x,
|
||||
y: target.y,
|
||||
z: target.z,
|
||||
block: set_to as u8,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(e) = broadcast_edit {
|
||||
let msg = ClientMsg::Edit {
|
||||
x: e.x,
|
||||
y: e.y,
|
||||
z: e.z,
|
||||
block: e.block,
|
||||
};
|
||||
if let Ok(s) = serde_json::to_string(&msg) {
|
||||
bridges::push_outbox(s);
|
||||
}
|
||||
}
|
||||
|
||||
if total_damage > 0 {
|
||||
self.body = self.body.take_damage(total_damage);
|
||||
bridges::set_status(self.body.hp, self.body.alive);
|
||||
}
|
||||
|
||||
// Periodic state broadcast.
|
||||
if connected && self.body.alive && real_time - self.last_net_send > 0.1 {
|
||||
self.last_net_send = real_time;
|
||||
let msg = ClientMsg::State {
|
||||
x: camera.position.x,
|
||||
y: self.body.feet.y,
|
||||
z: camera.position.z,
|
||||
yaw: camera.yaw,
|
||||
pitch: camera.pitch,
|
||||
};
|
||||
if let Ok(s) = serde_json::to_string(&msg) {
|
||||
bridges::push_outbox(s);
|
||||
}
|
||||
}
|
||||
|
||||
let outline = if self.body.alive {
|
||||
hit.map(|(h, _)| h)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
drop(world_borrow);
|
||||
drop(camera_borrow);
|
||||
self.render_frame(settings, outline);
|
||||
let _ = WORLD_RADIUS;
|
||||
}
|
||||
|
||||
/// Render-only path used by both the active tick and the paused
|
||||
/// branch. One place to assemble camera upload + visibility cull +
|
||||
/// remote-player buffer + draw.
|
||||
fn render_frame(&self, settings: Settings, outline: Option<IVec3>) {
|
||||
let camera_borrow = self.camera.borrow();
|
||||
let Some(camera) = camera_borrow.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let world_borrow = self.world.borrow();
|
||||
let world_ref = world_borrow.as_ref();
|
||||
let visible = if let Some(w) = world_ref {
|
||||
compute_visible_chunks(w, camera, settings.render_dist)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let remotes = bridges::snapshot_remote_players();
|
||||
if let Some(r) = self.renderer.borrow_mut().as_mut() {
|
||||
r.set_outline(outline);
|
||||
r.set_visible(visible);
|
||||
r.set_remote_players(&remotes);
|
||||
r.upload_camera(camera, self.shader_time);
|
||||
match r.render() {
|
||||
Ok(()) => {}
|
||||
Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
|
||||
let size = r.window.inner_size();
|
||||
r.resize(size.width, size.height);
|
||||
}
|
||||
Err(e) => log::error!("render error: {e:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
298
src/bridges.rs
Normal file
298
src/bridges.rs
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
//! JS ⇄ Rust bridges and per-process mutable state. This module owns
|
||||
//! every `thread_local!` cell in the project; everything else accesses
|
||||
//! them through the typed accessor functions below.
|
||||
//!
|
||||
//! Why this split: it's the only place we *have* to do shared mutable
|
||||
//! state, because wasm-bindgen callbacks from JS need a destination for
|
||||
//! events (touch input, network messages, UI state changes). The rest
|
||||
//! of the codebase stays pure by going through accessors that return
|
||||
//! values or take closures.
|
||||
use crate::sim::input::TouchBridge;
|
||||
use glam::Vec3;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
|
||||
// ---------------- Data types stored in the bridges ----------------
|
||||
|
||||
/// User-tunable settings, mirrored from the JS settings menu.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Settings {
|
||||
pub mouse_sens: f32,
|
||||
pub fov_deg: f32,
|
||||
pub render_dist: f32,
|
||||
pub paused: bool,
|
||||
/// Multiplier on real time used to drive the day/night cycle.
|
||||
/// 0 = frozen, 1 = realtime, up to 8x for fast-forward playtesting.
|
||||
pub time_scale: f32,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mouse_sens: 0.005,
|
||||
fov_deg: 70.0,
|
||||
render_dist: 240.0,
|
||||
paused: false,
|
||||
time_scale: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// HP + alive flag exposed to JS (HUD, death screen) plus a "JS asked
|
||||
/// for respawn" one-shot.
|
||||
pub struct GameStatus {
|
||||
pub hp: u8,
|
||||
pub alive: bool,
|
||||
pub respawn_requested: bool,
|
||||
}
|
||||
|
||||
/// WebSocket + remote-player state.
|
||||
#[derive(Default)]
|
||||
pub struct NetBridge {
|
||||
pub connected: bool,
|
||||
pub inbox: Vec<String>,
|
||||
pub outbox: Vec<String>,
|
||||
pub pending_name: Option<String>,
|
||||
pub my_id: Option<u32>,
|
||||
pub remote_players: HashMap<u32, RemotePlayer>,
|
||||
}
|
||||
|
||||
/// Best-known state of another connected player. Built from
|
||||
/// `proto::PlayerInfo` after `net::parse_inbox` returns it as a
|
||||
/// `PlayerList` event.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RemotePlayer {
|
||||
pub name: String,
|
||||
pub pos: Vec3,
|
||||
pub yaw: f32,
|
||||
pub pitch: f32,
|
||||
}
|
||||
|
||||
// ---------------- Thread-local storage ----------------
|
||||
|
||||
thread_local! {
|
||||
static TOUCH_BRIDGE: RefCell<TouchBridge> = RefCell::new(TouchBridge::default());
|
||||
static GAME_STATUS: RefCell<GameStatus> = RefCell::new(GameStatus {
|
||||
hp: 20,
|
||||
alive: true,
|
||||
respawn_requested: false,
|
||||
});
|
||||
static NET_BRIDGE: RefCell<NetBridge> = RefCell::new(NetBridge::default());
|
||||
static SETTINGS: RefCell<Settings> = RefCell::new(Settings::default());
|
||||
}
|
||||
|
||||
// ---------------- Public typed accessors ----------------
|
||||
//
|
||||
// Callers never touch the RefCells directly — they go through these.
|
||||
// That keeps the call sites readable and means the storage strategy can
|
||||
// change (e.g. to a single `Box<Mutex<…>>`) without touching the App.
|
||||
|
||||
/// Read the current settings as a value snapshot.
|
||||
pub fn current_settings() -> Settings {
|
||||
SETTINGS.with(|s| *s.borrow())
|
||||
}
|
||||
|
||||
/// Read a snapshot of the touch bridge (clone, no live borrow held).
|
||||
pub fn snapshot_touch_bridge() -> TouchBridge {
|
||||
TOUCH_BRIDGE.with(|b| b.borrow().clone())
|
||||
}
|
||||
|
||||
/// Mutate the touch bridge in-place under a closure.
|
||||
pub fn with_touch_bridge<R>(f: impl FnOnce(&mut TouchBridge) -> R) -> R {
|
||||
TOUCH_BRIDGE.with(|b| f(&mut b.borrow_mut()))
|
||||
}
|
||||
|
||||
/// Whether the player is in touch-input mode.
|
||||
pub fn is_touch_mode() -> bool {
|
||||
TOUCH_BRIDGE.with(|b| b.borrow().touch_mode)
|
||||
}
|
||||
|
||||
/// Clear movement holds, jump, sprint, look deltas, and the
|
||||
/// break/place one-shots. Used by the paused branch of the tick.
|
||||
pub fn clear_touch_inputs() {
|
||||
with_touch_bridge(|br| {
|
||||
br.forward = false;
|
||||
br.back = false;
|
||||
br.left = false;
|
||||
br.right = false;
|
||||
br.jump = false;
|
||||
br.sprint = false;
|
||||
br.look_dx = 0.0;
|
||||
br.look_dy = 0.0;
|
||||
br.break_pressed = false;
|
||||
br.place_pressed = false;
|
||||
});
|
||||
}
|
||||
|
||||
/// Read & clear the "JS requested a respawn" one-shot.
|
||||
pub fn take_respawn_request() -> bool {
|
||||
GAME_STATUS.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
let r = s.respawn_requested;
|
||||
s.respawn_requested = false;
|
||||
r
|
||||
})
|
||||
}
|
||||
|
||||
/// Publish HP + alive to JS (HUD, death screen).
|
||||
pub fn set_status(hp: u8, alive: bool) {
|
||||
GAME_STATUS.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
s.hp = hp;
|
||||
s.alive = alive;
|
||||
});
|
||||
}
|
||||
|
||||
/// Drain the incoming WebSocket inbox to be parsed by `net::parse_inbox`.
|
||||
pub fn take_inbox() -> Vec<String> {
|
||||
NET_BRIDGE.with(|n| std::mem::take(&mut n.borrow_mut().inbox))
|
||||
}
|
||||
|
||||
/// Queue an outbound message for JS to send over the WebSocket.
|
||||
pub fn push_outbox(msg: String) {
|
||||
NET_BRIDGE.with(|n| n.borrow_mut().outbox.push(msg));
|
||||
}
|
||||
|
||||
/// Mutate the net bridge in-place under a closure.
|
||||
pub fn with_net_bridge<R>(f: impl FnOnce(&mut NetBridge) -> R) -> R {
|
||||
NET_BRIDGE.with(|n| f(&mut n.borrow_mut()))
|
||||
}
|
||||
|
||||
/// `(connected, pending_name)` — used by App::tick when sending the
|
||||
/// initial Hello on a new connection.
|
||||
pub fn net_connection_snapshot() -> (bool, Option<String>) {
|
||||
NET_BRIDGE.with(|n| {
|
||||
let mut n = n.borrow_mut();
|
||||
(n.connected, n.pending_name.take())
|
||||
})
|
||||
}
|
||||
|
||||
/// Just the `connected` flag, no clearing.
|
||||
pub fn is_connected() -> bool {
|
||||
NET_BRIDGE.with(|n| n.borrow().connected)
|
||||
}
|
||||
|
||||
/// Set the local player id assigned by the server.
|
||||
pub fn set_my_id(id: u32) {
|
||||
NET_BRIDGE.with(|n| n.borrow_mut().my_id = Some(id));
|
||||
}
|
||||
|
||||
/// Snapshot the current set of remote players for rendering. Returns a
|
||||
/// fresh `Vec` so the bridge borrow doesn't span the render call.
|
||||
pub fn snapshot_remote_players() -> Vec<RemotePlayer> {
|
||||
NET_BRIDGE.with(|n| n.borrow().remote_players.values().cloned().collect())
|
||||
}
|
||||
|
||||
// ---------------- wasm-bindgen JS interface ----------------
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod wasm_api {
|
||||
use super::{GAME_STATUS, NET_BRIDGE, SETTINGS, TOUCH_BRIDGE};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn set_touch_mode(on: bool) {
|
||||
TOUCH_BRIDGE.with(|b| b.borrow_mut().touch_mode = on);
|
||||
}
|
||||
#[wasm_bindgen]
|
||||
pub fn touch_move(forward: bool, back: bool, left: bool, right: bool) {
|
||||
TOUCH_BRIDGE.with(|b| {
|
||||
let mut br = b.borrow_mut();
|
||||
br.forward = forward;
|
||||
br.back = back;
|
||||
br.left = left;
|
||||
br.right = right;
|
||||
});
|
||||
}
|
||||
#[wasm_bindgen]
|
||||
pub fn touch_jump(on: bool) {
|
||||
TOUCH_BRIDGE.with(|b| b.borrow_mut().jump = on);
|
||||
}
|
||||
#[wasm_bindgen]
|
||||
pub fn touch_sprint(on: bool) {
|
||||
TOUCH_BRIDGE.with(|b| b.borrow_mut().sprint = on);
|
||||
}
|
||||
#[wasm_bindgen]
|
||||
pub fn touch_look(dx: f32, dy: f32) {
|
||||
TOUCH_BRIDGE.with(|b| {
|
||||
let mut br = b.borrow_mut();
|
||||
br.look_dx += dx;
|
||||
br.look_dy += dy;
|
||||
});
|
||||
}
|
||||
#[wasm_bindgen]
|
||||
pub fn touch_break() {
|
||||
TOUCH_BRIDGE.with(|b| b.borrow_mut().break_pressed = true);
|
||||
}
|
||||
#[wasm_bindgen]
|
||||
pub fn touch_place() {
|
||||
TOUCH_BRIDGE.with(|b| b.borrow_mut().place_pressed = true);
|
||||
}
|
||||
#[wasm_bindgen]
|
||||
pub fn select_block(b: u8) {
|
||||
TOUCH_BRIDGE.with(|x| x.borrow_mut().selected = Some(b));
|
||||
}
|
||||
#[wasm_bindgen]
|
||||
pub fn get_hp() -> u8 {
|
||||
GAME_STATUS.with(|s| s.borrow().hp)
|
||||
}
|
||||
#[wasm_bindgen]
|
||||
pub fn is_alive() -> bool {
|
||||
GAME_STATUS.with(|s| s.borrow().alive)
|
||||
}
|
||||
#[wasm_bindgen]
|
||||
pub fn respawn() {
|
||||
GAME_STATUS.with(|s| s.borrow_mut().respawn_requested = true);
|
||||
}
|
||||
#[wasm_bindgen]
|
||||
pub fn set_player_name(name: String) {
|
||||
NET_BRIDGE.with(|n| n.borrow_mut().pending_name = Some(name));
|
||||
}
|
||||
#[wasm_bindgen]
|
||||
pub fn on_ws_message(text: String) {
|
||||
NET_BRIDGE.with(|n| n.borrow_mut().inbox.push(text));
|
||||
}
|
||||
#[wasm_bindgen]
|
||||
pub fn on_ws_open() {
|
||||
NET_BRIDGE.with(|n| n.borrow_mut().connected = true);
|
||||
}
|
||||
#[wasm_bindgen]
|
||||
pub fn on_ws_close() {
|
||||
NET_BRIDGE.with(|n| {
|
||||
let mut n = n.borrow_mut();
|
||||
n.connected = false;
|
||||
n.my_id = None;
|
||||
n.remote_players.clear();
|
||||
});
|
||||
}
|
||||
#[wasm_bindgen]
|
||||
pub fn drain_outbox() -> Vec<String> {
|
||||
NET_BRIDGE.with(|n| std::mem::take(&mut n.borrow_mut().outbox))
|
||||
}
|
||||
#[wasm_bindgen]
|
||||
pub fn set_paused(on: bool) {
|
||||
SETTINGS.with(|s| s.borrow_mut().paused = on);
|
||||
}
|
||||
#[wasm_bindgen]
|
||||
pub fn set_mouse_sens(s: f32) {
|
||||
SETTINGS.with(|x| x.borrow_mut().mouse_sens = s.clamp(0.0005, 0.05));
|
||||
}
|
||||
#[wasm_bindgen]
|
||||
pub fn set_fov(deg: f32) {
|
||||
SETTINGS.with(|x| x.borrow_mut().fov_deg = deg.clamp(40.0, 110.0));
|
||||
}
|
||||
#[wasm_bindgen]
|
||||
pub fn set_render_distance(blocks: f32) {
|
||||
SETTINGS.with(|x| x.borrow_mut().render_dist = blocks.clamp(32.0, 1200.0));
|
||||
}
|
||||
#[wasm_bindgen]
|
||||
pub fn set_time_scale(s: f32) {
|
||||
SETTINGS.with(|x| x.borrow_mut().time_scale = s.clamp(0.0, 8.0));
|
||||
}
|
||||
/// Clears all bridge input (move/look/buttons) — called on init,
|
||||
/// pause, and visibility-change so we never resume with stale state.
|
||||
#[wasm_bindgen]
|
||||
pub fn reset_input() {
|
||||
super::clear_touch_inputs();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
pub mod app;
|
||||
pub mod bridges;
|
||||
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;
|
||||
pub mod world;
|
||||
|
||||
use winit::event_loop::EventLoop;
|
||||
|
|
@ -26,7 +29,7 @@ pub fn run() {
|
|||
let event_loop = EventLoop::new().unwrap();
|
||||
event_loop.set_control_flow(winit::event_loop::ControlFlow::Poll);
|
||||
#[allow(unused_mut)]
|
||||
let mut app = state::App::default();
|
||||
let mut app = app::App::default();
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
|
|
|
|||
153
src/mesh.rs
153
src/mesh.rs
|
|
@ -1,6 +1,6 @@
|
|||
use crate::world::{Block, Chunk, Face, World, CHUNK_HEIGHT, CHUNK_SIZE};
|
||||
use bytemuck::{Pod, Zeroable};
|
||||
use glam::IVec3;
|
||||
use glam::{IVec3, Vec3};
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, Pod, Zeroable, Debug)]
|
||||
|
|
@ -267,6 +267,99 @@ pub fn build_chunk_mesh(world: &World, chunk: &Chunk) -> ChunkMesh {
|
|||
ChunkMesh { vertices, indices }
|
||||
}
|
||||
|
||||
/// Emit 6 faces of a box centered at `center`, rotated around Y by
|
||||
/// `yaw`, extending `half_extents` in each local-space axis. Each face
|
||||
/// winds CCW outward — verified by `oriented_box_winds_correctly`.
|
||||
///
|
||||
/// Used by the renderer to draw remote players (body + head boxes).
|
||||
/// Lives in `mesh.rs` because the output is `Vertex`es with normals and
|
||||
/// AO, same as the chunk meshes.
|
||||
pub fn emit_oriented_box(
|
||||
center: Vec3,
|
||||
half_extents: Vec3,
|
||||
yaw: f32,
|
||||
color: [f32; 3],
|
||||
verts: &mut Vec<Vertex>,
|
||||
indices: &mut Vec<u32>,
|
||||
) {
|
||||
let cos_y = yaw.cos();
|
||||
let sin_y = yaw.sin();
|
||||
let rotate_xz = |x: f32, z: f32| (x * cos_y - z * sin_y, x * sin_y + z * cos_y);
|
||||
let world_pt = |lx: f32, ly: f32, lz: f32| {
|
||||
let (rx, rz) = rotate_xz(lx, lz);
|
||||
[center.x + rx, center.y + ly, center.z + rz]
|
||||
};
|
||||
let world_normal = |nx: f32, ny: f32, nz: f32| {
|
||||
let (rx, rz) = rotate_xz(nx, nz);
|
||||
[rx, ny, rz]
|
||||
};
|
||||
|
||||
let (hx, hy, hz) = (half_extents.x, half_extents.y, half_extents.z);
|
||||
let faces: [([f32; 3], [[f32; 3]; 4]); 6] = [
|
||||
([1.0, 0.0, 0.0], [
|
||||
[ hx, -hy, -hz],
|
||||
[ hx, -hy, hz],
|
||||
[ hx, hy, hz],
|
||||
[ hx, hy, -hz],
|
||||
]),
|
||||
([-1.0, 0.0, 0.0], [
|
||||
[-hx, -hy, hz],
|
||||
[-hx, -hy, -hz],
|
||||
[-hx, hy, -hz],
|
||||
[-hx, hy, hz],
|
||||
]),
|
||||
([0.0, 1.0, 0.0], [
|
||||
[-hx, hy, -hz],
|
||||
[ hx, hy, -hz],
|
||||
[ hx, hy, hz],
|
||||
[-hx, hy, hz],
|
||||
]),
|
||||
([0.0, -1.0, 0.0], [
|
||||
[-hx, -hy, hz],
|
||||
[ hx, -hy, hz],
|
||||
[ hx, -hy, -hz],
|
||||
[-hx, -hy, -hz],
|
||||
]),
|
||||
([0.0, 0.0, 1.0], [
|
||||
[ hx, -hy, hz],
|
||||
[-hx, -hy, hz],
|
||||
[-hx, hy, hz],
|
||||
[ hx, hy, hz],
|
||||
]),
|
||||
([0.0, 0.0, -1.0], [
|
||||
[-hx, -hy, -hz],
|
||||
[ hx, -hy, -hz],
|
||||
[ hx, hy, -hz],
|
||||
[-hx, hy, -hz],
|
||||
]),
|
||||
];
|
||||
for (n_local, corners_local) in faces {
|
||||
let n_world = world_normal(n_local[0], n_local[1], n_local[2]);
|
||||
let base = verts.len() as u32;
|
||||
for c in corners_local {
|
||||
verts.push(Vertex {
|
||||
pos: world_pt(c[0], c[1], c[2]),
|
||||
color,
|
||||
normal: n_world,
|
||||
leaf: 0.0,
|
||||
ao: 1.0,
|
||||
});
|
||||
}
|
||||
indices.extend_from_slice(&[base, base + 2, base + 1, base, base + 3, base + 2]);
|
||||
}
|
||||
}
|
||||
|
||||
/// FNV-1a 32-bit hash of a string. Used to colorize remote-player
|
||||
/// boxes deterministically by display name.
|
||||
pub fn name_hash(s: &str) -> u32 {
|
||||
let mut h: u32 = 2166136261;
|
||||
for b in s.bytes() {
|
||||
h ^= b as u32;
|
||||
h = h.wrapping_mul(16777619);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -423,4 +516,62 @@ mod tests {
|
|||
assert!(CHUNK_SIZE > 0);
|
||||
assert!(CHUNK_HEIGHT > 0);
|
||||
}
|
||||
|
||||
// ---------- emit_oriented_box ----------
|
||||
|
||||
#[test]
|
||||
fn oriented_box_emits_six_quads() {
|
||||
let mut v = vec![];
|
||||
let mut i = vec![];
|
||||
emit_oriented_box(Vec3::ZERO, Vec3::splat(0.5), 0.0, [1.0; 3], &mut v, &mut i);
|
||||
assert_eq!(v.len(), 24);
|
||||
assert_eq!(i.len(), 36);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oriented_box_winds_correctly_at_any_yaw() {
|
||||
for &yaw in &[0.0_f32, 0.7, 1.5708, 3.14, -1.0, 5.0] {
|
||||
let mut v = vec![];
|
||||
let mut i = vec![];
|
||||
emit_oriented_box(
|
||||
Vec3::new(5.0, 7.0, -3.0),
|
||||
Vec3::new(0.3, 0.6, 0.2),
|
||||
yaw,
|
||||
[1.0; 3],
|
||||
&mut v,
|
||||
&mut i,
|
||||
);
|
||||
for tri in i.chunks_exact(3) {
|
||||
let a = v[tri[0] as usize].pos;
|
||||
let b = v[tri[1] as usize].pos;
|
||||
let c = v[tri[2] as usize].pos;
|
||||
let n = v[tri[0] as usize].normal;
|
||||
let geo = cross_normal(a, b, c);
|
||||
let dot = geo[0] * n[0] + geo[1] * n[1] + geo[2] * n[2];
|
||||
assert!(dot > 0.0, "yaw {} tri winds opposite normal {:?}", yaw, n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oriented_box_normal_rotates_with_yaw() {
|
||||
let mut v = vec![];
|
||||
let mut i = vec![];
|
||||
let yaw = std::f32::consts::FRAC_PI_2;
|
||||
emit_oriented_box(Vec3::ZERO, Vec3::splat(0.5), yaw, [1.0; 3], &mut v, &mut i);
|
||||
let n = v[0].normal;
|
||||
assert!(
|
||||
(n[0]).abs() < 1e-5 && (n[2] - 1.0).abs() < 1e-5,
|
||||
"+X normal at yaw 90° expected (0,0,1), got {:?}",
|
||||
n
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_hash_is_deterministic_and_distinct() {
|
||||
let a = name_hash("alice");
|
||||
let b = name_hash("bob");
|
||||
assert_eq!(a, name_hash("alice"));
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
631
src/render/mod.rs
Normal file
631
src/render/mod.rs
Normal 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::bridges::RemotePlayer;
|
||||
use crate::camera::Camera;
|
||||
use crate::mesh::{build_chunk_mesh, emit_oriented_box, name_hash, Vertex};
|
||||
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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
}
|
||||
237
src/render/pipelines.rs
Normal file
237
src/render/pipelines.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
72
src/render/scene_target.rs
Normal file
72
src/render/scene_target.rs
Normal 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
42
src/render/uniform.rs
Normal 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,
|
||||
}
|
||||
251
src/shader.wgsl
251
src/shader.wgsl
|
|
@ -1,34 +1,46 @@
|
|||
// Voxel-game world/sky/outline pipelines.
|
||||
//
|
||||
// The constants `DAY_PERIOD` and `SUN_OFFSET` are NOT defined here —
|
||||
// they are injected at the top of this file at module-load time by
|
||||
// `crate::shader_source::wgsl_constants_header()`. The same values
|
||||
// drive `crate::sim::lighting`, so the visual sun direction can never
|
||||
// disagree with the mechanical one consumed by mob burn / plant growth /
|
||||
// shade pathfinding.
|
||||
//
|
||||
// Layout (top to bottom):
|
||||
// 1. Camera uniform + accessors
|
||||
// 2. Sky horizon math (one source of truth: sky_dome)
|
||||
// 3. Atmosphere extras (clouds, sun/moon discs, stars) → sky_color
|
||||
// 4. Terrain lighting decomposition (ambient, sun, fog, leaf jitter)
|
||||
// 5. Pipelines: terrain (vs_main/fs_main), sky background, outline.
|
||||
|
||||
// ---------------- 1. Camera ----------------
|
||||
|
||||
struct Camera {
|
||||
view_proj: mat4x4<f32>,
|
||||
inv_view_proj: mat4x4<f32>,
|
||||
eye: vec4<f32>,
|
||||
/// .x = scene time in seconds (drives day/night cycle + leaf sway)
|
||||
misc: vec4<f32>,
|
||||
/// .y/.z/.w reserved
|
||||
frame: vec4<f32>,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> camera: Camera;
|
||||
|
||||
// ---------------- Time-of-day primitives ----------------
|
||||
//
|
||||
// One in-game day takes DAY_PERIOD seconds. The sun sweeps an east-to-west
|
||||
// arc (cos/sin on the same plane) with a small constant tilt on Z so it
|
||||
// isn't dead-flat. Game starts at noon (offset = 0.25 cycles).
|
||||
fn scene_time() -> f32 { return camera.frame.x; }
|
||||
fn eye_world() -> vec3<f32> { return camera.eye.xyz; }
|
||||
|
||||
const DAY_PERIOD: f32 = 300.0;
|
||||
const SUN_OFFSET: f32 = 0.25;
|
||||
// ---------------- 2. Sky horizon math (shared with ambient) ----------------
|
||||
|
||||
fn sun_direction(t: f32) -> vec3<f32> {
|
||||
let a = (t / DAY_PERIOD + SUN_OFFSET) * 6.28318530718;
|
||||
return normalize(vec3<f32>(cos(a), sin(a), 0.25));
|
||||
}
|
||||
|
||||
// Smooth 0..1 going from -0.05 (sun barely under horizon, blue hour) up
|
||||
// to 0.20 (clearly above the horizon, full daylight).
|
||||
fn day_strength(sun: vec3<f32>) -> f32 {
|
||||
return smoothstep(-0.05, 0.20, sun.y);
|
||||
}
|
||||
|
||||
// Twilight peaks while the sun is near the horizon — sunrise + sunset.
|
||||
fn twilight_amount(sun: vec3<f32>) -> f32 {
|
||||
let above = smoothstep(-0.10, 0.05, sun.y);
|
||||
let high = smoothstep(0.05, 0.30, sun.y);
|
||||
|
|
@ -40,7 +52,29 @@ fn sun_tint(sun: vec3<f32>) -> vec3<f32> {
|
|||
return mix(vec3<f32>(1.00, 0.95, 0.85), vec3<f32>(1.00, 0.55, 0.30), twi);
|
||||
}
|
||||
|
||||
// ---------------- Cheap 2D fbm for clouds ----------------
|
||||
// Horizon → zenith gradient. Used by both:
|
||||
// - the sky background fragment shader (with clouds/sun layered on)
|
||||
// - terrain hemisphere ambient (sample the dome in surface-normal dir
|
||||
// so vertical faces inherit the bright daytime horizon).
|
||||
//
|
||||
// One function = one source of truth for "what color is the sky at this
|
||||
// angle?" — when the palette is tuned, both consumers update together.
|
||||
fn sky_dome(dir: vec3<f32>, sun: vec3<f32>) -> vec3<f32> {
|
||||
let day = day_strength(sun);
|
||||
let twi = twilight_amount(sun);
|
||||
let zenith_day = vec3<f32>(0.30, 0.55, 0.88);
|
||||
let zenith_night = vec3<f32>(0.02, 0.03, 0.10);
|
||||
let horizon_day = vec3<f32>(0.82, 0.92, 0.99);
|
||||
let horizon_twi = vec3<f32>(1.00, 0.55, 0.28);
|
||||
let horizon_night = vec3<f32>(0.03, 0.04, 0.10);
|
||||
let zenith = mix(zenith_night, zenith_day, day);
|
||||
let horizon = mix(mix(horizon_night, horizon_day, day), horizon_twi, twi);
|
||||
let up = clamp(dir.y, -1.0, 1.0);
|
||||
let gradient_t = pow(max(up, 0.0), 0.55);
|
||||
return mix(horizon, zenith, gradient_t);
|
||||
}
|
||||
|
||||
// ---------------- 3. Atmosphere extras ----------------
|
||||
|
||||
fn hash21(p: vec2<f32>) -> f32 {
|
||||
return fract(sin(dot(p, vec2<f32>(127.1, 311.7))) * 43758.5453);
|
||||
|
|
@ -69,27 +103,7 @@ fn fbm2(p_in: vec2<f32>) -> f32 {
|
|||
return v;
|
||||
}
|
||||
|
||||
// Just the horizon→zenith gradient — no clouds, no sun, no stars. Used by
|
||||
// the terrain shader to compute hemisphere ambient: each fragment samples
|
||||
// the dome in its surface-normal direction so vertical faces inherit the
|
||||
// bright daytime horizon instead of a flat dim ambient.
|
||||
fn sky_dome(dir: vec3<f32>, sun: vec3<f32>) -> vec3<f32> {
|
||||
let day = day_strength(sun);
|
||||
let twi = twilight_amount(sun);
|
||||
let zenith_day = vec3<f32>(0.30, 0.55, 0.88);
|
||||
let zenith_night = vec3<f32>(0.02, 0.03, 0.10);
|
||||
let horizon_day = vec3<f32>(0.82, 0.92, 0.99);
|
||||
let horizon_twi = vec3<f32>(1.00, 0.55, 0.28);
|
||||
let horizon_night = vec3<f32>(0.03, 0.04, 0.10);
|
||||
let zenith = mix(zenith_night, zenith_day, day);
|
||||
let horizon = mix(mix(horizon_night, horizon_day, day), horizon_twi, twi);
|
||||
let up = clamp(dir.y, -1.0, 1.0);
|
||||
let gradient_t = pow(max(up, 0.0), 0.55);
|
||||
return mix(horizon, zenith, gradient_t);
|
||||
}
|
||||
|
||||
// Cheap "stars" — high-frequency hash on view direction, threshold to
|
||||
// keep only ~0.2% of cells lit.
|
||||
// High-frequency hash on view direction, threshold to keep ~0.2% lit.
|
||||
fn star_field(dir: vec3<f32>) -> f32 {
|
||||
if (dir.y <= 0.0) { return 0.0; }
|
||||
let cell = floor(dir * 220.0);
|
||||
|
|
@ -97,45 +111,30 @@ fn star_field(dir: vec3<f32>) -> f32 {
|
|||
return step(0.997, h);
|
||||
}
|
||||
|
||||
// ---------------- Sky ----------------
|
||||
//
|
||||
// `dir` is the *view* direction from camera into the scene (unit vector).
|
||||
// Composes a horizon→zenith gradient that re-tones with sun height,
|
||||
// twinklers + cloud streaks + sun + moon discs.
|
||||
|
||||
// Composite sky: dome + below-horizon dim + stars + clouds + sun + moon.
|
||||
// `dir` is the view direction from camera into the scene (unit vector).
|
||||
fn sky_color(dir: vec3<f32>) -> vec3<f32> {
|
||||
let t = camera.misc.x;
|
||||
let t = scene_time();
|
||||
let sun = sun_direction(t);
|
||||
let day = day_strength(sun);
|
||||
let twi = twilight_amount(sun);
|
||||
let night = clamp(1.0 - day, 0.0, 1.0);
|
||||
|
||||
let zenith_day = vec3<f32>(0.30, 0.55, 0.88);
|
||||
let zenith_night = vec3<f32>(0.02, 0.03, 0.10);
|
||||
let horizon_day = vec3<f32>(0.78, 0.88, 0.96);
|
||||
let horizon_twi = vec3<f32>(1.00, 0.55, 0.28);
|
||||
let horizon_night = vec3<f32>(0.03, 0.04, 0.10);
|
||||
|
||||
let zenith = mix(zenith_night, zenith_day, day);
|
||||
let horizon = mix(mix(horizon_night, horizon_day, day), horizon_twi, twi);
|
||||
|
||||
let up = clamp(dir.y, -1.0, 1.0);
|
||||
let gradient_t = pow(max(up, 0.0), 0.55);
|
||||
var sky = mix(horizon, zenith, gradient_t);
|
||||
var sky = sky_dome(dir, sun);
|
||||
|
||||
// Below-horizon slight darken so the world below the player still feels grounded.
|
||||
let up = clamp(dir.y, -1.0, 1.0);
|
||||
let below = step(up, 0.0) * 0.2;
|
||||
sky = sky * (1.0 - below);
|
||||
|
||||
// Stars: fade in as day strength drops. Slight twinkle via time-based jitter.
|
||||
let night_amt = clamp(1.0 - day, 0.0, 1.0);
|
||||
if (night_amt > 0.05) {
|
||||
// Stars: fade in at night with slight twinkle.
|
||||
if (night > 0.05) {
|
||||
let st = star_field(dir);
|
||||
let twinkle = 0.7 + 0.3 * sin(t * 6.0 + dir.x * 100.0 + dir.z * 130.0);
|
||||
sky = sky + vec3<f32>(st * night_amt * twinkle);
|
||||
sky = sky + vec3<f32>(st * night * twinkle);
|
||||
}
|
||||
|
||||
// Cloud layer — fbm scrolled across an imaginary plane high above. Only
|
||||
// visible looking upward (dir.y > 0). Cheap: 4 octaves of value noise.
|
||||
// Cloud layer — fbm scrolled across an imaginary plane high above.
|
||||
if (dir.y > 0.05) {
|
||||
let proj = dir.xz / dir.y;
|
||||
let scroll = vec2<f32>(t * 0.004, t * 0.0015);
|
||||
|
|
@ -147,24 +146,87 @@ fn sky_color(dir: vec3<f32>) -> vec3<f32> {
|
|||
sky = mix(sky, cloud_col, mask * (0.55 + 0.25 * day));
|
||||
}
|
||||
|
||||
// Sun disc + halo. Disc only visible in daytime (no sun glow underground).
|
||||
// Sun disc + halo.
|
||||
let sun_col = sun_tint(sun);
|
||||
let cos_s = max(dot(dir, sun), 0.0);
|
||||
let disc = pow(cos_s, 800.0) * 1.5 * smoothstep(-0.05, 0.05, sun.y);
|
||||
let halo = pow(cos_s, 5.0) * 0.20 * day;
|
||||
sky = sky + sun_col * (disc + halo);
|
||||
|
||||
// Moon disc — opposite the sun, faint white. Only at night.
|
||||
// Moon disc — opposite the sun, faint white, night only.
|
||||
let moon = -sun;
|
||||
let cos_m = max(dot(dir, moon), 0.0);
|
||||
let moon_disc = pow(cos_m, 700.0) * 0.9;
|
||||
let moon_halo = pow(cos_m, 24.0) * 0.06;
|
||||
sky = sky + vec3<f32>(0.86, 0.89, 0.96) * (moon_disc + moon_halo) * night_amt;
|
||||
sky = sky + vec3<f32>(0.86, 0.89, 0.96) * (moon_disc + moon_halo) * night;
|
||||
|
||||
return sky;
|
||||
}
|
||||
|
||||
// ---------------- Terrain ----------------
|
||||
// ---------------- 4. Terrain lighting decomposition ----------------
|
||||
//
|
||||
// Each piece is a named function so `fs_main` reads as a pipeline:
|
||||
//
|
||||
// lit = base_color *
|
||||
// ( ambient_term × ambient_strength + direct_sun_term × sun_color )
|
||||
// × ao × leaf_jitter
|
||||
// final = apply_fog( lit, dist, view_dir )
|
||||
|
||||
/// Hemisphere ambient — samples the sky dome in the surface-normal
|
||||
/// direction so vertical faces inherit the bright horizon during day.
|
||||
/// Bottom faces fade toward an earth-bounce color so they're not dead
|
||||
/// black.
|
||||
fn ambient_term(normal: vec3<f32>, sun: vec3<f32>, day: f32) -> vec3<f32> {
|
||||
let sky_in_normal = sky_dome(normal, sun);
|
||||
let earth_day = vec3<f32>(0.20, 0.18, 0.14);
|
||||
let earth_night = vec3<f32>(0.03, 0.03, 0.04);
|
||||
let earth = mix(earth_night, earth_day, day);
|
||||
let face_up = clamp(normal.y * 0.5 + 0.5, 0.0, 1.0);
|
||||
return mix(earth, sky_in_normal, face_up);
|
||||
}
|
||||
|
||||
/// Direct-sun Lambert term, gated by sun visibility above the horizon.
|
||||
/// Returns a scalar 0..1 — caller multiplies by `sun_tint(sun)` for
|
||||
/// color.
|
||||
///
|
||||
/// NOTE: This is an *unoccluded* Lambert. The mechanical sunlight
|
||||
/// predicate (`crate::sim::lighting::is_in_direct_sun`) is more
|
||||
/// accurate — it ray-traces the voxel grid. When real shadows land,
|
||||
/// this function will multiply by an occlusion factor that approximates
|
||||
/// that ray test (shadow map / voxel raymarch / etc).
|
||||
fn direct_sun_term(normal: vec3<f32>, sun: vec3<f32>) -> f32 {
|
||||
let ndl = max(dot(normal, sun), 0.0);
|
||||
let sun_visible = smoothstep(-0.05, 0.10, sun.y);
|
||||
return ndl * sun_visible;
|
||||
}
|
||||
|
||||
/// Per-pixel value-noise jitter on leaf surfaces so the canopy doesn't
|
||||
/// read as a flat green. 0.88 .. 1.06 range.
|
||||
fn leaf_jitter(world_pos: vec3<f32>) -> f32 {
|
||||
let n = fract(sin(dot(floor(world_pos * 1.3), vec3<f32>(12.9898, 78.233, 37.719))) * 43758.5453);
|
||||
return 0.88 + n * 0.18;
|
||||
}
|
||||
|
||||
/// Distance fog. Returns 0 (no fog) → 1 (fully obscured).
|
||||
fn fog_factor(dist: f32) -> f32 {
|
||||
let fog_start = 90.0;
|
||||
let fog_end = 320.0;
|
||||
return clamp((dist - fog_start) / (fog_end - fog_start), 0.0, 1.0);
|
||||
}
|
||||
|
||||
/// Blend `lit` toward sky color along the view ray when the fragment
|
||||
/// is far enough to be fogged. Defers the (expensive) full `sky_color`
|
||||
/// call until the factor is actually nonzero.
|
||||
fn apply_fog(lit: vec3<f32>, dist: f32, view_dir: vec3<f32>) -> vec3<f32> {
|
||||
let t = fog_factor(dist);
|
||||
if (t <= 0.001) {
|
||||
return lit;
|
||||
}
|
||||
let sky = sky_color(-view_dir);
|
||||
return mix(lit, sky, t);
|
||||
}
|
||||
|
||||
// ---------------- 5a. Terrain pipeline ----------------
|
||||
|
||||
struct VsIn {
|
||||
@location(0) pos: vec3<f32>,
|
||||
|
|
@ -187,9 +249,9 @@ struct VsOut {
|
|||
fn vs_main(in: VsIn) -> VsOut {
|
||||
var pos = in.pos;
|
||||
if (in.leaf > 0.5) {
|
||||
let t = camera.misc.x;
|
||||
let t = scene_time();
|
||||
let phase = pos.x * 0.35 + pos.z * 0.27 + pos.y * 0.11;
|
||||
let sway = sin(t * 1.6 + phase) * 0.045;
|
||||
let sway = sin(t * 1.6 + phase) * 0.045;
|
||||
let sway2 = cos(t * 1.1 + phase * 1.3) * 0.035;
|
||||
pos.x = pos.x + sway;
|
||||
pos.z = pos.z + sway2;
|
||||
|
|
@ -207,65 +269,35 @@ fn vs_main(in: VsIn) -> VsOut {
|
|||
|
||||
@fragment
|
||||
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
|
||||
let t = camera.misc.x;
|
||||
let t = scene_time();
|
||||
let sun = sun_direction(t);
|
||||
let day = day_strength(sun);
|
||||
let sun_col = sun_tint(sun);
|
||||
|
||||
let n = normalize(in.normal);
|
||||
let ndl = max(dot(n, sun), 0.0);
|
||||
let sun_visible = smoothstep(-0.05, 0.10, sun.y);
|
||||
let sun_term = ndl * sun_visible;
|
||||
|
||||
// Hemisphere ambient — *sample* the sky dome in the normal direction
|
||||
// instead of lerping two constants. A vertical face (n.y ≈ 0) picks up
|
||||
// the bright horizon, a top face (n.y ≈ 1) the (darker) zenith, a
|
||||
// bottom face the earth-bounce. This is the cheap analogue of an
|
||||
// integrated environment light and is what makes daytime sides not
|
||||
// look like night.
|
||||
let sky_in_normal = sky_dome(n, sun);
|
||||
let earth_down_day = vec3<f32>(0.20, 0.18, 0.14);
|
||||
let earth_down_night = vec3<f32>(0.03, 0.03, 0.04);
|
||||
let earth_down = mix(earth_down_night, earth_down_day, day);
|
||||
let face_up = clamp(n.y * 0.5 + 0.5, 0.0, 1.0);
|
||||
let ambient_col = mix(earth_down, sky_in_normal, face_up);
|
||||
// Higher strength than before — outdoor diffuse skylight is roughly
|
||||
// 10–20% of direct sun in reality. The old 0.45 cap was making sides
|
||||
// read as if it were dusk during the day.
|
||||
// Lighting pipeline:
|
||||
// ambient + direct_sun → modulated by base color → AO → leaf jitter → fog
|
||||
let ambient = ambient_term(n, sun, day);
|
||||
let ambient_strength = mix(0.25, 0.85, day);
|
||||
let sun_term = direct_sun_term(n, sun);
|
||||
let sun_col = sun_tint(sun);
|
||||
|
||||
let lighting = ambient_col * ambient_strength + sun_col * sun_term;
|
||||
var lit = in.color * lighting;
|
||||
let lighting = ambient * ambient_strength + sun_col * sun_term;
|
||||
var lit = in.color * lighting * in.ao;
|
||||
|
||||
// Bake-time per-vertex ambient occlusion.
|
||||
lit = lit * in.ao;
|
||||
|
||||
// Per-pixel value noise on leaves so the canopy doesn't look uniform.
|
||||
if (in.leaf > 0.5) {
|
||||
let n2 = fract(sin(dot(floor(in.world_pos * 1.3), vec3<f32>(12.9898, 78.233, 37.719))) * 43758.5453);
|
||||
lit = lit * (0.88 + n2 * 0.18);
|
||||
lit = lit * leaf_jitter(in.world_pos);
|
||||
}
|
||||
|
||||
let to_eye = camera.eye.xyz - in.world_pos;
|
||||
let dist = length(to_eye);
|
||||
let to_eye = eye_world() - in.world_pos;
|
||||
let dist = length(to_eye);
|
||||
let view_dir = -to_eye / max(dist, 0.0001);
|
||||
|
||||
let fog_start = 90.0;
|
||||
let fog_end = 320.0;
|
||||
let fog_t = clamp((dist - fog_start) / (fog_end - fog_start), 0.0, 1.0);
|
||||
var color = lit;
|
||||
if (fog_t > 0.001) {
|
||||
// Only pay for the full sky lookup if the fragment is actually
|
||||
// fogged enough to read it. Saves the cloud/fbm cost on near
|
||||
// geometry.
|
||||
let sky = sky_color(-view_dir);
|
||||
color = mix(lit, sky, fog_t);
|
||||
}
|
||||
|
||||
let color = apply_fog(lit, dist, view_dir);
|
||||
return vec4<f32>(color, 1.0);
|
||||
}
|
||||
|
||||
// ---- Sky background (full-screen triangle) ----
|
||||
// ---------------- 5b. Sky background (full-screen triangle) ----------------
|
||||
|
||||
struct SkyOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
|
|
@ -290,11 +322,11 @@ fn vs_sky(@builtin(vertex_index) idx: u32) -> SkyOut {
|
|||
fn fs_sky(in: SkyOut) -> @location(0) vec4<f32> {
|
||||
let far_h = camera.inv_view_proj * vec4<f32>(in.ndc.x, in.ndc.y, 1.0, 1.0);
|
||||
let world_pos = far_h.xyz / far_h.w;
|
||||
let dir = normalize(world_pos - camera.eye.xyz);
|
||||
let dir = normalize(world_pos - eye_world());
|
||||
return vec4<f32>(sky_color(dir), 1.0);
|
||||
}
|
||||
|
||||
// ---- Outline ----
|
||||
// ---------------- 5c. Outline ----------------
|
||||
|
||||
@vertex
|
||||
fn vs_outline(@location(0) pos: vec3<f32>) -> @builtin(position) vec4<f32> {
|
||||
|
|
@ -305,4 +337,3 @@ fn vs_outline(@location(0) pos: vec3<f32>) -> @builtin(position) vec4<f32> {
|
|||
fn fs_outline() -> @location(0) vec4<f32> {
|
||||
return vec4<f32>(0.05, 0.05, 0.07, 1.0);
|
||||
}
|
||||
|
||||
|
|
|
|||
54
src/shader_source.rs
Normal file
54
src/shader_source.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
//! Assembles WGSL shader source by prepending a constants header
|
||||
//! generated from Rust. Single source of truth for `DAY_PERIOD` and
|
||||
//! `SUN_OFFSET`: change them in `sim::lighting` and both Rust *and* the
|
||||
//! shader update together.
|
||||
//!
|
||||
//! No build script needed — the header is built at runtime when the
|
||||
//! shader module is created (once per Renderer init, negligible cost).
|
||||
use crate::sim::lighting::{DAY_PERIOD, SUN_OFFSET};
|
||||
|
||||
/// WGSL header injected at the top of `shader.wgsl`. Mirrors the Rust
|
||||
/// constants in `sim::lighting`.
|
||||
pub fn wgsl_constants_header() -> String {
|
||||
format!(
|
||||
"// ---- AUTO-INJECTED FROM sim::lighting (see shader_source.rs) ----\n\
|
||||
const DAY_PERIOD: f32 = {day:.6};\n\
|
||||
const SUN_OFFSET: f32 = {off:.6};\n\
|
||||
// ---- end injected constants ----\n\n",
|
||||
day = DAY_PERIOD,
|
||||
off = SUN_OFFSET,
|
||||
)
|
||||
}
|
||||
|
||||
/// Full WGSL source for the world/sky/outline pipelines: injected
|
||||
/// constants header + the static `shader.wgsl` body.
|
||||
pub fn terrain_shader_source() -> String {
|
||||
format!("{}{}", wgsl_constants_header(), include_str!("shader.wgsl"))
|
||||
}
|
||||
|
||||
/// Post-process pipeline source. Doesn't depend on the shared
|
||||
/// constants; passed through for symmetry with `terrain_shader_source`.
|
||||
pub fn post_shader_source() -> &'static str {
|
||||
include_str!("post.wgsl")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn header_contains_the_constants() {
|
||||
let h = wgsl_constants_header();
|
||||
assert!(h.contains("DAY_PERIOD"));
|
||||
assert!(h.contains("SUN_OFFSET"));
|
||||
assert!(h.contains(&format!("{:.6}", DAY_PERIOD)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terrain_shader_source_contains_header_and_body() {
|
||||
let src = terrain_shader_source();
|
||||
assert!(src.contains("DAY_PERIOD"));
|
||||
assert!(src.contains("fn sun_direction"));
|
||||
assert!(src.contains("fn fs_main"));
|
||||
}
|
||||
}
|
||||
228
src/sim/lighting.rs
Normal file
228
src/sim/lighting.rs
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
//! Canonical sun/sky/lighting math — the single source of truth shared
|
||||
//! between the renderer (visual shading) and game systems (mob burn,
|
||||
//! plant growth, AI pathfinding around shade).
|
||||
//!
|
||||
//! The WGSL shader gets the same constants via runtime concatenation
|
||||
//! (see `crate::render::shader_source`), so changing `DAY_PERIOD` here
|
||||
//! updates the shader too. This is the architectural guarantee that
|
||||
//! prevents the "mob burns while standing in visible shade" class of
|
||||
//! bug — visual and mechanical sun direction can never drift.
|
||||
use crate::world::World;
|
||||
use glam::{IVec3, Vec3};
|
||||
|
||||
/// One in-game day in real seconds. Multiplied by the user's
|
||||
/// `time_scale` setting before being summed each tick.
|
||||
pub const DAY_PERIOD: f32 = 300.0;
|
||||
/// Phase offset so a fresh world starts at noon (0.25 cycles past dawn).
|
||||
pub const SUN_OFFSET: f32 = 0.25;
|
||||
/// Y above which the sun-occlusion ray is considered to have escaped
|
||||
/// the world (open sky). Must be ≥ `world::CHUNK_HEIGHT`.
|
||||
pub const SUN_RAY_TOP_Y: i32 = 128;
|
||||
/// Hard cap on DDA steps so a degenerate near-horizontal ray can't loop
|
||||
/// forever. 512 voxels is well past any reasonable world width.
|
||||
const SUN_RAY_MAX_STEPS: u32 = 512;
|
||||
|
||||
/// World-space sun direction at time `t`. Matches the WGSL
|
||||
/// `sun_direction(t)` bit for bit — both consume the same `DAY_PERIOD`
|
||||
/// and `SUN_OFFSET` (the WGSL constants are emitted from these via
|
||||
/// `render::shader_source::wgsl_constants_header()`).
|
||||
pub fn sun_direction(t: f32) -> Vec3 {
|
||||
let a = (t / DAY_PERIOD + SUN_OFFSET) * std::f32::consts::TAU;
|
||||
Vec3::new(a.cos(), a.sin(), 0.25).normalize()
|
||||
}
|
||||
|
||||
/// 0..1 going from "sun barely under horizon (blue hour)" to "clearly
|
||||
/// above horizon (full daylight)". Same formula as the shader's
|
||||
/// `day_strength`.
|
||||
pub fn day_strength(sun: Vec3) -> f32 {
|
||||
smoothstep(-0.05, 0.20, sun.y)
|
||||
}
|
||||
|
||||
/// Peaks while the sun is near the horizon — sunrise + sunset.
|
||||
pub fn twilight_amount(sun: Vec3) -> f32 {
|
||||
smoothstep(-0.10, 0.05, sun.y) - smoothstep(0.05, 0.30, sun.y)
|
||||
}
|
||||
|
||||
/// All four lighting scalars derived once for a tick. Cheaper than
|
||||
/// recomputing each piece, and gives downstream systems a single value
|
||||
/// to pass around.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct LightingFrame {
|
||||
pub time: f32,
|
||||
pub sun: Vec3,
|
||||
pub day: f32,
|
||||
pub twilight: f32,
|
||||
}
|
||||
|
||||
impl LightingFrame {
|
||||
pub fn at(t: f32) -> Self {
|
||||
let sun = sun_direction(t);
|
||||
Self {
|
||||
time: t,
|
||||
sun,
|
||||
day: day_strength(sun),
|
||||
twilight: twilight_amount(sun),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mechanically night: nothing burns, plants don't grow, AI is
|
||||
/// free to wander in the open. True iff the sun is below the
|
||||
/// horizon.
|
||||
pub fn is_night(&self) -> bool {
|
||||
self.sun.y <= 0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Is point `p` directly lit by the sun in `world` at time `t`?
|
||||
///
|
||||
/// 3D DDA from `p` toward the sun until the ray either escapes through
|
||||
/// the world top (lit) or hits a solid voxel (occluded). The
|
||||
/// mechanical sunlight test — consumed by mob burn checks, plant
|
||||
/// growth, daylight sensors, shade pathfinding. Visual shadows in the
|
||||
/// shader should approximate the *same* predicate.
|
||||
pub fn is_in_direct_sun(world: &World, p: Vec3, t: f32) -> bool {
|
||||
let sun = sun_direction(t);
|
||||
if sun.y <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// DDA setup. Walking from `p` along `sun` through unit voxels.
|
||||
let mut cell = IVec3::new(
|
||||
p.x.floor() as i32,
|
||||
p.y.floor() as i32,
|
||||
p.z.floor() as i32,
|
||||
);
|
||||
let step = IVec3::new(
|
||||
sign_to_step(sun.x),
|
||||
sign_to_step(sun.y),
|
||||
sign_to_step(sun.z),
|
||||
);
|
||||
let t_delta = Vec3::new(
|
||||
axis_t_delta(sun.x),
|
||||
axis_t_delta(sun.y),
|
||||
axis_t_delta(sun.z),
|
||||
);
|
||||
let mut t_max = Vec3::new(
|
||||
first_t_max(p.x, sun.x, cell.x),
|
||||
first_t_max(p.y, sun.y, cell.y),
|
||||
first_t_max(p.z, sun.z, cell.z),
|
||||
);
|
||||
|
||||
for _ in 0..SUN_RAY_MAX_STEPS {
|
||||
if cell.y >= SUN_RAY_TOP_Y {
|
||||
return true;
|
||||
}
|
||||
if t_max.x < t_max.y && t_max.x < t_max.z {
|
||||
cell.x += step.x;
|
||||
t_max.x += t_delta.x;
|
||||
} else if t_max.y < t_max.z {
|
||||
cell.y += step.y;
|
||||
t_max.y += t_delta.y;
|
||||
} else {
|
||||
cell.z += step.z;
|
||||
t_max.z += t_delta.z;
|
||||
}
|
||||
if world.get_block(cell).solid() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Ran out of steps — treat as open sky (the alternative is "hangs
|
||||
// forever on degenerate rays", which is worse).
|
||||
true
|
||||
}
|
||||
|
||||
fn sign_to_step(v: f32) -> i32 {
|
||||
if v > 0.0 {
|
||||
1
|
||||
} else if v < 0.0 {
|
||||
-1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fn axis_t_delta(v: f32) -> f32 {
|
||||
if v.abs() < 1e-6 {
|
||||
f32::INFINITY
|
||||
} else {
|
||||
1.0 / v.abs()
|
||||
}
|
||||
}
|
||||
|
||||
fn first_t_max(origin: f32, dir: f32, cell: i32) -> f32 {
|
||||
if dir.abs() < 1e-6 {
|
||||
f32::INFINITY
|
||||
} else if dir > 0.0 {
|
||||
((cell + 1) as f32 - origin) / dir
|
||||
} else {
|
||||
(origin - cell as f32) / -dir
|
||||
}
|
||||
}
|
||||
|
||||
fn smoothstep(edge0: f32, edge1: f32, x: f32) -> f32 {
|
||||
let t = ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0);
|
||||
t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::world::{natural_surface_y, Block, World};
|
||||
|
||||
#[test]
|
||||
fn sun_below_horizon_means_night_and_no_direct_sun() {
|
||||
// Midnight = half a day past noon (t such that sun.y < 0).
|
||||
let t = DAY_PERIOD * 0.5;
|
||||
let lf = LightingFrame::at(t);
|
||||
assert!(lf.is_night(), "midnight should report as night, sun={:?}", lf.sun);
|
||||
let world = World::new();
|
||||
assert!(!is_in_direct_sun(
|
||||
&world,
|
||||
Vec3::new(0.5, 200.0, 0.5),
|
||||
t
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_sky_above_terrain_is_lit_at_noon() {
|
||||
let world = World::new();
|
||||
let lf = LightingFrame::at(0.0); // game starts at noon
|
||||
assert!(!lf.is_night());
|
||||
assert!(lf.sun.y > 0.0, "noon sun must be above horizon");
|
||||
let high_above = Vec3::new(0.5, 200.0, 0.5);
|
||||
assert!(is_in_direct_sun(&world, high_above, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_under_solid_overhead_is_occluded() {
|
||||
// Place a stone block right above (0.5, surface+1, 0.5) and
|
||||
// check that the player feet position below it is shaded.
|
||||
let mut world = World::new();
|
||||
let surface = natural_surface_y(0, 0);
|
||||
let feet_y = surface + 1;
|
||||
// Cap the column with stones so any upward ray hits one.
|
||||
for y in (feet_y + 2)..(feet_y + 12) {
|
||||
world.set_block(IVec3::new(0, y, 0), Block::Stone);
|
||||
}
|
||||
let p = Vec3::new(0.5, feet_y as f32 + 0.1, 0.5);
|
||||
assert!(!is_in_direct_sun(&world, p, 0.0), "covered point must be occluded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn day_strength_and_twilight_match_shader_ramps() {
|
||||
// Spot-checks at known sun heights.
|
||||
assert!(day_strength(Vec3::new(0.0, -0.5, 0.0)).abs() < 1e-5);
|
||||
assert!((day_strength(Vec3::new(0.0, 1.0, 0.0)) - 1.0).abs() < 1e-5);
|
||||
assert!(twilight_amount(Vec3::new(0.0, 0.05, 0.0)) > 0.5);
|
||||
assert!(twilight_amount(Vec3::new(0.0, 1.0, 0.0)).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sun_direction_constants_round_trip() {
|
||||
// At t=0 (noon by construction) the angle is SUN_OFFSET turns,
|
||||
// i.e. sun is at cos(τ·0.25) = 0, sin(τ·0.25) = 1 → straight up
|
||||
// (plus the small Z tilt). The Y should be the dominant axis.
|
||||
let s = sun_direction(0.0);
|
||||
assert!(s.y > 0.9, "noon sun y should be ~1, got {}", s.y);
|
||||
}
|
||||
}
|
||||
|
|
@ -18,10 +18,14 @@ pub mod collision;
|
|||
pub mod edit;
|
||||
pub mod event;
|
||||
pub mod input;
|
||||
pub mod lighting;
|
||||
pub mod physics;
|
||||
pub mod spawn;
|
||||
pub mod visibility;
|
||||
|
||||
pub use body::PlayerBody;
|
||||
pub use event::SimEvent;
|
||||
pub use input::{merge_held, Input, TouchBridge};
|
||||
pub use lighting::{is_in_direct_sun, sun_direction, LightingFrame};
|
||||
pub use physics::{step_movement, MoveInput, MoveOutcome};
|
||||
pub use visibility::compute_visible_chunks;
|
||||
|
|
|
|||
60
src/sim/visibility.rs
Normal file
60
src/sim/visibility.rs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
//! Frustum + radial culling for chunk visibility. Pure function on
|
||||
//! `(World, Camera, render_dist) -> Vec<ChunkCoord>`.
|
||||
use crate::camera::Camera;
|
||||
use crate::world::{World, CHUNK_HEIGHT, CHUNK_SIZE};
|
||||
use glam::{IVec3, Vec3};
|
||||
|
||||
/// Returns the chunk coordinates whose AABB intersects the camera's
|
||||
/// view frustum AND whose center is within `render_dist` on XZ. The
|
||||
/// cheap radial cull runs first — frustum tests are skipped for chunks
|
||||
/// already beyond the configured render distance.
|
||||
pub fn compute_visible_chunks(world: &World, camera: &Camera, render_dist: f32) -> Vec<IVec3> {
|
||||
let planes = frustum_planes(camera);
|
||||
let dist2 = render_dist * render_dist;
|
||||
let cam_xz = (camera.position.x, camera.position.z);
|
||||
|
||||
world
|
||||
.chunks
|
||||
.keys()
|
||||
.filter(|coord| {
|
||||
let cx = (coord.x * CHUNK_SIZE + CHUNK_SIZE / 2) as f32;
|
||||
let cz = (coord.z * CHUNK_SIZE + CHUNK_SIZE / 2) as f32;
|
||||
let dx = cx - cam_xz.0;
|
||||
let dz = cz - cam_xz.1;
|
||||
dx * dx + dz * dz <= dist2
|
||||
})
|
||||
.filter(|coord| chunk_in_frustum(**coord, &planes))
|
||||
.copied()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn frustum_planes(camera: &Camera) -> [[f32; 4]; 6] {
|
||||
let m = camera.view_proj().to_cols_array_2d();
|
||||
let row = |i: usize| [m[0][i], m[1][i], m[2][i], m[3][i]];
|
||||
let r0 = row(0);
|
||||
let r1 = row(1);
|
||||
let r2 = row(2);
|
||||
let r3 = row(3);
|
||||
let add = |a: [f32; 4], b: [f32; 4]| [a[0] + b[0], a[1] + b[1], a[2] + b[2], a[3] + b[3]];
|
||||
let sub = |a: [f32; 4], b: [f32; 4]| [a[0] - b[0], a[1] - b[1], a[2] - b[2], a[3] - b[3]];
|
||||
[add(r3, r0), sub(r3, r0), add(r3, r1), sub(r3, r1), add(r3, r2), sub(r3, r2)]
|
||||
}
|
||||
|
||||
fn chunk_in_frustum(coord: IVec3, planes: &[[f32; 4]; 6]) -> bool {
|
||||
let min = Vec3::new(
|
||||
(coord.x * CHUNK_SIZE) as f32,
|
||||
0.0,
|
||||
(coord.z * CHUNK_SIZE) as f32,
|
||||
);
|
||||
let max = min + Vec3::new(CHUNK_SIZE as f32, CHUNK_HEIGHT as f32, CHUNK_SIZE as f32);
|
||||
for p in planes {
|
||||
// p-vertex (the AABB corner furthest along plane normal).
|
||||
let px = if p[0] > 0.0 { max.x } else { min.x };
|
||||
let py = if p[1] > 0.0 { max.y } else { min.y };
|
||||
let pz = if p[2] > 0.0 { max.z } else { min.z };
|
||||
if p[0] * px + p[1] * py + p[2] * pz + p[3] < 0.0 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
1945
src/state.rs
1945
src/state.rs
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue