Compare commits

...

4 commits

Author SHA1 Message Date
Maximus Gorog
55276b7ce0 Phase 4: split state.rs → bridges.rs + app.rs
state.rs is gone. Its content split by responsibility:

  src/bridges.rs (~290 lines)
    The only place we own shared mutable state. Holds the four
    thread_locals (touch input, game status, network, settings) and
    the wasm-bindgen JS interface that mutates them. Exposes a typed
    accessor API — current_settings(), snapshot_touch_bridge(),
    with_touch_bridge(f), is_touch_mode(), clear_touch_inputs(),
    take_respawn_request(), set_status(hp, alive), take_inbox(),
    push_outbox(msg), with_net_bridge(f), net_connection_snapshot(),
    is_connected(), set_my_id(id), snapshot_remote_players(). Callers
    never touch the RefCells; if storage ever moves to a Mutex or
    OnceCell, only this file changes.

  src/app.rs (~510 lines)
    The App + winit ApplicationHandler + tick + drain_net_inbox +
    do_respawn + render_frame + FrameClock. Uses the bridges
    accessors instead of poking thread_locals directly, so the call
    sites read as a pipeline rather than a chain of `.with(|x|
    x.borrow_mut())`.

  src/lib.rs
    Modules now declared in alphabetical order:
      app, bridges, camera, mesh, net, proto, render,
      shader_source, sim, world.
    `run()` constructs `app::App::default()` instead of the old
    `state::App`.

  src/render/mod.rs
    `use crate::bridges::RemotePlayer` (was `crate::state::`).

Net effect of the four phases combined: state.rs at the start of
alpha-0.0.2 was 2500 lines doing everything; today the same logic
spans nine focused modules totalling ~3000 lines (with full doc
comments). 53 tests pass, native + wasm release build green, server
build green.
2026-05-23 23:27:03 -06:00
Maximus Gorog
549662ddc8 Phase 3: extract render/ module from state.rs
state.rs is now 870 lines (down from 1700+). All GPU code moved to a
dedicated render/ module.

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

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

Tests: 53/53 pass. Native + wasm release build clean.
2026-05-23 23:21:47 -06:00
Maximus Gorog
accbf67bf2 Phase 2: shader decomposition + Rust-shared constants
shader.wgsl restructured:
  - Section headers: Camera → Sky horizon → Atmosphere extras → Terrain
    lighting decomposition → Pipelines.
  - sky_dome() is now the single source of truth for the horizon→zenith
    mix; sky_color() builds on it instead of duplicating the gradient.
  - fs_main decomposed into named helpers:
      ambient_term(n, sun, day)    hemisphere ambient via sky_dome lookup
      direct_sun_term(n, sun)      Lambert × visibility ramp
      leaf_jitter(world_pos)       per-pixel canopy variation
      fog_factor(dist)             0..1 fog ramp
      apply_fog(lit, dist, view)   defers sky_color call until needed
  - Camera.misc renamed to Camera.frame with named accessors
    scene_time() / eye_world() so callsites read intent.
  - DAY_PERIOD / SUN_OFFSET REMOVED from the shader — injected at the
    top by shader_source::wgsl_constants_header(). One source of truth
    in sim::lighting now drives both Rust and WGSL; mob burn and
    visible sun direction can never disagree.

New src/shader_source.rs:
  - wgsl_constants_header()  Rust → WGSL constants prelude
  - terrain_shader_source()  header + shader.wgsl, used at Renderer::new
  - post_shader_source()     pass-through

state.rs CameraUniform:
  - misc → frame to match the renamed WGSL field
  - Renderer uses the assembled shader source instead of include_str! directly

Tests: 53 passing (added 2 shader_source assembly tests). Native build,
wasm release build, server build all green.
2026-05-23 23:15:25 -06:00
Maximus Gorog
989de4f43d Phase 1: sim/lighting + sim/visibility + mesh utilities
New modules:
  src/sim/lighting.rs   - DAY_PERIOD, SUN_OFFSET constants; sun_direction,
                          day_strength, twilight_amount, LightingFrame::at;
                          is_in_direct_sun(world, p, t) via 3D DDA — the
                          mechanical sunlight predicate that future mob
                          burn / plant growth / shade pathfinding will all
                          consume. Mirrors the shader's sun math exactly
                          (Phase 2 will wire the WGSL side to consume the
                          same constants from this module).
  src/sim/visibility.rs - compute_visible_chunks moved out of state.rs;
                          frustum_planes + chunk_in_frustum decomposed.

Moves into mesh.rs:
  emit_oriented_box, name_hash + their tests. These are mesh utilities
  (Vertex output, color hash for remote-player boxes), not GPU shell.

state.rs:
  - drops the moved functions
  - imports compute_visible_chunks / emit_oriented_box / name_hash from
    their new homes
  - 230 lines lighter

Tests: 51 passing (up from 45). New coverage: sun-below-horizon=>night,
open-sky-at-noon-is-lit, occluded-by-overhead-block, ramp shapes match
the WGSL smoothstep, name_hash determinism.
2026-05-23 23:12:01 -06:00
14 changed files with 2561 additions and 2058 deletions

637
src/app.rs Normal file
View 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
View 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();
}
}

View file

@ -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"))]
{

View file

@ -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
View file

@ -0,0 +1,631 @@
//! GPU shell. Owns the wgpu device + surface + pipelines + per-chunk
//! buffers + remote-player buffers + offscreen scene-color target +
//! post pipeline. Everything visual happens here; pure simulation
//! (sim/) and net parsing (net/) feed it via the App tick.
//!
//! Sub-modules:
//! pipelines.rs pipeline + bind-group-layout factories
//! scene_target.rs depth / scene-color / post bind-group helpers
//! uniform.rs CameraUniform + OutlineVertex + ChunkBuffers
pub mod pipelines;
pub mod scene_target;
pub mod uniform;
use crate::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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
}

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

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

View file

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

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

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

View file

@ -1,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 horizonzenith 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 horizonzenith 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
// 1020% 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
View 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
View 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);
}
}

View file

@ -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
View 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
}

File diff suppressed because it is too large Load diff