Testing is a dev process — point the harness at a local build you can
edit, rebuild, and screenshot in seconds. Pointing it at the prod
deploy by default was wrong: the deploy lags local code by a deploy
cycle, so visual changes you make wouldn't appear there until rebuilt
on the Linode.
test/launch.py:
- DEFAULT_URL is now http://localhost:8080/.
- Friendly pre-flight check: if --url is localhost and nothing is
listening on the port, print a clear "start ./run.sh --no-tunnel"
message and exit 1. Avoids the silent ERR_CONNECTION_REFUSED
failure mode.
- --url https://voxel.mxvs.art/ still works for one-off remote
sanity checks.
test/README.md:
- Lead with the dev-loop instruction: terminal 1 runs the local
server, terminal 2 runs launch.py, terminal 3 drives scenarios.
- Note the "pointing at deploy" path as a rarely-used escape hatch.
Mirrors the cucucaracha (lacucarachanews) toolkit pattern adapted for
the voxel game:
bridges.rs adds TestCommand + Telemetry plumbing:
- thread_local TEST_COMMANDS queue + TELEMETRY snapshot.
- drain_test_commands() called by App::tick at frame start.
- publish_telemetry(t) called at frame end.
- wasm_api exports: set_scene_time, teleport, look_at, plus
getters get_scene_time / get_position / get_camera_angles.
app.rs:
- drain_test_commands() applies SetSceneTime / Teleport / LookAt
before physics integrates. Teleport zeroes velocity and syncs the
camera to feet+EYE_HEIGHT.
- publish_telemetry() at end of tick exposes scene state to JS.
test/:
launch.py Open Chromium with persistent profile + CDP:9222.
Navigates to https://voxel.mxvs.art by default;
--url for local dev.
peek.py Attach via CDP, screenshot canvas, dump telemetry
(scene_time, position, camera angles, hp). Read-only.
run.py Execute a YAML scenario:
wait_for, wait, eval, key, mouse_move, mouse,
screenshot, assert
Key allowlist prevents stray scenarios from sending
arbitrary input.
requirements.txt playwright + PyYAML.
README.md Setup, grammar, available bindings, why this
exists.
scenarios/
lighting-times-of-day.yaml Screenshots at noon / afternoon /
sunset / civil twilight / midnight
/ sunrise. Verifies the Round A
sunset fixes by visual diff.
god-rays-look-at-sun.yaml Pointed at the sun at four altitudes
to inspect the Round B shafts.
voxel-construction-darkness.yaml Visual baseline for the sky_vis
bake from Round D.
.gitignore Excludes the browser profile +
screenshots directory.
Visual regression workflow:
1. python3 launch.py
2. (separate terminal) python3 run.py scenarios/lighting-times-of-day.yaml
3. Compare screenshots/lighting-times-of-day_*.png against baseline.
Tests still 63 passing. Native + wasm release clean.
sim/lighting.rs:
- New compute_ambience(world, pos, normal) -> VertexAmbience
returns BOTH sky_vis AND bounce_color from the same 8-ray
hemisphere cast. No double walks of the voxel grid.
- walks_until_solid(world, origin, dir) -> Option<Vec3> is the
primitive: None = escaped to sky, Some(color) = first solid hit.
Used by compute_ambience to accumulate the bounce_color average.
- sky_visibility is now a thin wrapper around compute_ambience for
tests and clarity.
world.rs:
- Block::average_color() returns the mean across all 6 faces — the
contribution this block makes to a neighbor's bounce when hit by
a hemisphere ray.
- Block::material_id() returns 0 (matte) for most blocks, 1
(specular) for Ice + Snow. Reserved 2 for future emissive blocks.
mesh.rs:
- Vertex grows `bounce_mat: [f32; 4]` packing baked bounce color
(rgb) + material id (a). Float32x4 attribute at @location(6).
- build_chunk_mesh now calls compute_ambience for each quad corner
(same one-pass ray-cast that produced sky_vis already, plus the
bounce accumulation). Material id taken from cell.block.
shader.wgsl:
- VsIn / VsOut grow bounce_mat (vec4).
- ambient_term now takes the per-vertex bounce_albedo instead of a
hard-coded gray-brown. A red brick wall thus casts a faint red
bounce on dirt next to it — real GI hint, baked once at mesh
time, zero cost at runtime per fragment.
- material_for(id) lookup: Phong specular for ice/snow (m.specular
= 0.45 with cos⁴⁸ half-vector); emission slot reserved.
- fs_main pipeline now includes the specular + emission terms.
- Camera.frame keeps Round C accessor names; ambient_strength
untouched.
Tests: 63 passing. Native + wasm release clean.
Deferred from this round (own sessions): #18 water (new pipeline +
animation), #19 transparent/cutout (new blend state), #8 wider AO
(current 4-corner is sufficient for now; the wider-occlusion effect
is now coming through bounce_color anyway).
Visual change: bounce-tinted ambient (red walls glow red onto
neighbors); ice + snow get specular highlight in direct sun.
shader.wgsl:
- Camera.frame layout now documented slot-by-slot in the struct
comment; named accessors scene_time() / exposure_bias() / eye_world()
so call sites read as intent. exposure_bias plumbed (default 1.0)
as the foundation for a future Settings.exposure slider. [#20]
- Leaves now get sun-aware backlit translucency: leaf_translucency()
peaks when the sun rakes across the leaf plane AND comes from
behind the surface. Cheap stand-in for subsurface scattering —
reads as "dappled sun through canopy" without per-pixel ray
sampling. [#25]
render/mod.rs:
- run_fullscreen_pass(encoder, label, target, pipeline, bgs, clear)
helper. The three post-chain steps (mask → shafts → composite)
are now three sequential calls instead of three copies of the
same begin_render_pass boilerplate. Adding a new effect (bloom,
motion blur, vignette) is one extra row. [#23]
- upload_camera fills frame[1] = 1.0 (exposure_bias default).
render/uniform.rs:
- CameraUniform.frame doc updated to enumerate each slot.
Tests: 63 passing. Native + wasm release clean.
New WGSL files:
mask.wgsl Sun-cone × sky-alpha mask at ¼ resolution. Marks sky
pixels (scene_color.a == 0) that fall within a tight
cone around the sun direction. Sun direction derived
from scene time via the injected DAY_PERIOD /
SUN_OFFSET constants — same source as sim::lighting,
so visual rays align with the mechanical sun.
shafts.wgsl Radial blur at ¼ resolution. Projects the sun
direction-at-infinity to screen space via view_proj,
steps 32 samples from each pixel toward the
sun_screen_pos accumulating mask intensity with
exponential decay, outputs sun-tinted ray color.
shader.wgsl:
- fs_sky now writes alpha = 0 so the mask pass can identify sky
pixels without a separate occluder pass. Terrain / outline /
remote-player pipelines continue writing alpha = 1.
post.wgsl (rewritten):
- Reads scene_color + shafts.
- Cheap edge-aware FXAA (5-tap diagonal blur, blends toward neighbor
average where luminance gradient exceeds threshold). Catches the
axis-aligned staircase aliasing that voxel games produce.
- Adds shafts additively before tonemap so rays go through the
filmic curve and don't blow out.
render/scene_target.rs:
- create_image_bind_group (single texture + sampler) — used by
mask pass (binds scene_color) and shafts pass (binds mask_view).
- create_composite_bind_group (scene + shafts + sampler) — used by
the final post pass.
- create_quarter_res_view (¹⁄₁₆ fillrate, RENDER_ATTACHMENT +
TEXTURE_BINDING) — used for both mask and shafts targets.
render/pipelines.rs:
- image_bgl / composite_bgl as separate layouts.
- fullscreen_pipeline factory replaces the post-specific one;
takes vs/fs entry-point names so mask, shafts, and post all
build through the same shape.
render/mod.rs:
- Renderer grows mask_view, shafts_view, image_bgl, composite_bgl,
mask_pipeline, shafts_pipeline, mask_bg, shafts_bg fields. The
old single-pass post_pipeline becomes the final composite pass.
- render() now does scene → mask → shafts → post (each in its own
encoder block).
- resize() recreates all three render targets and all three bind
groups in the right order.
Tests: 63 passing (added 2 for mask/shafts source assembly). Native
+ wasm release clean.
Visual change: when you face roughly toward the sun and there's
geometry blocking the disc, you'll see warm light shafts radiating
outward. FXAA softens the worst pixel-step edges. Tonemap (from
Round A) is now the final step of the new pipeline.
shader.wgsl:
- sky_dome: zenith now tints toward a dusky-purple zenith_twi during
twilight (was previously only the horizon picking up the warm tint).
Top faces at sunset will read warm through the sky_vis pathway
instead of staying cold blue. [#1]
- apply_fog: at twilight the fog tints toward sun_tint by twi×0.45,
so distant terrain reads warm against an orange sky instead of
cold. [#2]
- sky_color stars: gate flipped from (1 - day) — which still showed
stars during dusk while the sky was bright — to a direct
smoothstep on sun.y (-0.22..0.04). Stars now fade in only after
civil twilight. [#3]
- sun disc / halo: sharpness + intensity now altitude-dependent. At
zenith it's a sharp pinpoint (sharpness 800, intensity 1.5); near
the horizon it softens to a big atmospheric bloom (sharpness 160,
intensity 2.2). Halo also widens and brightens at low sun. [#4]
post.wgsl:
- ACES filmic tonemap (Narkowicz approximation) runs as the final
step. Brightens midtones, compresses highlights smoothly into
[0,1] instead of the previous hard clamp. Output stays linear; the
sRGB surface handles display encoding. Foundation for everything
HDR that follows (god rays, bloom). [#6]
The "side faces don't adjust to daytime ambiance" bug was caused by
conflating geometric sky visibility with radiometric sky intensity
into a single ambient_strength knob. This commit separates them.
sim/lighting.rs:
- Refactor: extract walks_to_sky(world, origin, dir) -> bool as the
shared DDA primitive. is_in_direct_sun now calls it.
- New: sky_visibility(world, pos, normal) -> f32. Casts 8 cosine-
weighted hemisphere rays through the voxel grid (Hammersley(2)
distribution, deterministic). Returns fraction that escape to
the world top. Pure function of (world, position, normal) — depends
only on the surrounding voxel construction. Architecturally, this
means a player-built sealed roof on the surface produces the same
sky_vis as the same enclosure underground; no "is this the surface
or underground?" hack anywhere.
- 4 new tests including the construction-invariance test that pins
"same enclosure geometry → same sky_vis regardless of world Y".
mesh.rs:
- Vertex gains a sky_vis: f32 field at @location(5).
- build_chunk_mesh computes sky_vis per quad corner via
sky_visibility (one ray-walk per vertex; amortized at mesh build,
free at runtime).
- emit_oriented_box sets sky_vis = 1.0 for remote-player boxes
(they float in open air).
- 2 new tests: open-top has high sky_vis, slab-covered top has low.
shader.wgsl:
- VsIn / VsOut grow @location(5) sky_vis: f32.
- ambient_term rewritten as the principled split:
sky_radiance(N) × sky_vis + bounce × (1 − sky_vis)
where bounce is sun-tinted ground albedo scaled by day. No more
`face_up = normal.y * 0.5 + 0.5` flat hemisphere assumption — the
per-vertex geometric weight does the work.
- ambient_strength bumped from mix(0.25, 0.85, day) to mix(0.35,
1.00, day) since sky_vis now carries the geometric attenuation.
Result: side faces in the open get bright sky-colored ambient at noon;
side faces in pits dim correctly; player-built shelters darken by
construction without any time-of-day weirdness. Same model also sets
up the bounce-color bake (Phase 2) and the CSM shadow infrastructure
(Phase 3) — both extend the per-vertex visibility-attribute pattern
introduced here.
Tests: 61 passing (up from 53). Native + wasm release + server clean.
Documents every module's responsibility, the tick pipeline, the
sim::lighting shared-truth invariant (sun direction shared between
Rust and WGSL via shader_source.rs), and the test coverage. Companion
to DEPLOY.md — that one covers ops, this one covers code.
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.
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.
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.
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.
Records the live production state (Linode IP, paths, redeploy command,
firewall + fail2ban + SSH hardening, TLS via Caddy, DNS via Namecheap
Advanced DNS, rollback steps, troubleshooting checklist) so a fresh
session can pick this up without re-deriving any of it.
Reshape the engine into a functional-core / imperative-shell split.
The 2500-line state.rs blob is now 1700 lines of mostly GPU plumbing
plus a thin tick() shell that composes pure transformations from the
new sim/ and net/ modules.
src/sim/ (no GPU, no winit, no thread-locals — all pure):
body.rs PlayerBody value type + take_damage / respawned_at
collision.rs AABB primitives, sweep_axis returning (Vec3, bool)
instead of mutating &mut Vec3
edit.rs block_from_u8, apply_edit, chunks_for_edit
event.rs SimEvent enum (Landed, VoidDeath, BlockEdited)
input.rs TouchBridge data type + merge_held
physics.rs step_movement(world, body, MoveInput) -> MoveOutcome
— the central morphism: one tick of player movement
+ collision + landing detection + void check, returns
a new body value and a list of events
spawn.rs find_safe_spawn, fall_damage
src/net/:
parse_inbox(Vec<String>) -> Vec<NetEvent>
Malformed lines drop silently; the shell folds typed events into
the world / remote-player map.
src/state.rs:
- App now holds a single PlayerBody value instead of scattered
velocity / on_ground / hp / alive / max_y_since_ground fields.
- tick() is a pipeline: collect input → merge_held → step_movement
→ fold sim events → block interaction → render_frame.
- drain_net_inbox() parses to events first, then applies.
- render_frame() extracted so the paused and active branches share
the upload + cull + draw path.
Tests: 45 passing (up from 33). New coverage for the physics step
itself: airborne gravity, jump-only-when-grounded, long-fall-emits-
Landed, void floor, dead-body-does-not-move. Net parsing tests for
malformed lines and round-trips. Body tests for damage / respawn.
Build: cargo test (45/45), cargo build --target wasm32-unknown-unknown
--lib --release, and the axum server all green.
Multi-stage Dockerfile compiles wasm client + axum server in one Rust
builder and copies into a debian:bookworm-slim runtime (non-root uid).
docker-compose.yml binds localhost:8080 by default; docker-compose.prod.yml
replaces ports with a Caddy reverse proxy on host 80/443 that talks to
the voxel container over the internal network. Caddy auto-issues Let's
Encrypt certs.
DEPLOY.md covers the three deployment modes (local-only, VPS with
Cloudflare or Caddy, Cloudflare Tunnel from a workstation).
- Greedy meshing now bakes per-vertex AO with 4-corner sampling and an
anisotropic-diagonal split when corner AO disagrees.
- WGSL: extracted sky_dome() for hemisphere ambient sampling so vertical
faces match the sun-side sky tint at day; ambient_strength mixed by
day strength instead of a flat constant.
- Step-1 post pipeline: render scene into an offscreen color texture,
pass-through to the surface. Foundation for FXAA/shafts that will
follow.
- Input bug: merge_held() now recomputes per tick from sticky keyboard +
live touch bridge, so releasing the joystick actually stops the
player (previous OR-into-self bug ate playtests).
- Touch UI hit-zones reordered (menu/hotbar above the joystick z-index);
hotbar widened to 10 slots with tap-to-select on mobile.
- find_safe_spawn anchors on natural_surface_y so spawn is deterministic
from noise — towers built at spawn no longer climb the spawn point.
- move_axis is sub-stepped (0.45-block max) so high-velocity falls can't
teleport the player inside terrain.