Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ RedotCraft: a Minecraft-like voxel sandbox built with **Redot Engine** (Godot 4
- Precipitation is biome-aware: a throttled `VoxelWorld.get_biome_id()` poll classifies the camera's biome through `BiomeCatalog.is_cold_biome()`/`is_wetland_biome()`. Cold biomes (snow/taiga/highlands/frozen sea) fall as snow and keep a light ambient snowfall when clear; swamps carry a low ground-mist field; every other biome falls as rain. `WeatherSystem` emits `ambience_changed(cold, wetland)` so `Main` crossfades the wind bed and `AudioManager` keeps the rain bed in step. Lightning strikes are scheduled only for uncovered, non-cold rain and surface through the `lightning(strength)` signal.
- `DayNightCycle.set_weather_dim(0..1)` owns the grading (sun/ambient energy, fog density, sky shader colors, cloud tint); `Main._apply_graphics()` sets `DayNightCycle.base_fog_density` so rain fog stacks on the preset value. `DayNightCycle.trigger_lightning()` adds a short decayed flash to sun/ambient/fog, and its `wind_strength` (scaled up by rain) drives the `wind_strength` shader global.
- Wind sway: the mesher writes a per-block wind weight into vertex color alpha (`ChunkMesher._wind`; leaves 1.0, non-emissive cross foliage 0.6, solid 0) and `world/block.gdshader` displaces those vertices, ramping in over the first 8 blocks of world height so ground clutter barely moves and the canopy sways as a whole. Solid blocks are untouched, and `COLOR.a` was previously unused.
- Leaf shadow proxy: the same `COLOR.a` weight doubles as the leaf marker in `world/block.gdshader`, which tests the built-in `IN_SHADOW_PASS` and writes opaque coverage for leaves so the shadow map rasterizes a solid silhouette instead of sampling mip-filtered binary leaf alpha (which made the canopy edge crawl). The visible pass is unchanged and non-leaf cutout blocks are unaffected. `Main._apply_graphics()` drives the `solid_leaf_shadows` uniform from the Advanced Graphics → Shadows "Solid Leaf Shadows" setting (default on; off restores the dappled cutout shadows). `tools/shadow_proxy_verify.gd` (headless) and `tools/shadow_proxy_measure.gd` (display) cover it.
- `DayNightCycle` also owns the time-of-day colour grade: it scales `adjustment_saturation`/`adjustment_contrast` down at night (`base_saturation`/`base_contrast` come from the graphics preset) and animates `glow_hdr_threshold`.
- `world/sky.gdshader` draws stars, the milky way band, and the phased moon (`moon_phase` uniform, 8 in-game day lunar cycle); `DayNightCycle` feeds it colors and `star_intensity` (0 during the day).

Expand Down Expand Up @@ -83,9 +84,10 @@ RedotCraft: a Minecraft-like voxel sandbox built with **Redot Engine** (Godot 4

- Parse check after editing scripts or scenes: `redot --editor --headless --path . --quit` (a nonzero "error" count means a script/scene fails to load).
- Headless verifiers, from the repo root (`redot --headless --path . --script res://tools/<file>`):
`worldgen_verify.gd` (parity, seams, continuity, roughness), `worldgen_biome_verify.gd` (region size, vegetation density), `worldgen_river_verify.gd` (river width, banks, floodplain, bed), `worldgen_island_verify.gd` (detached large/small island landmasses), `worldgen_lod_verify.gd` (LOD parity, canopies, patches, shading), `worldgen_tree_verify.gd`, `worldgen_cactus_verify.gd`, `audio_verify.gd` (bank/buses/mappings), `weather_verify.gd` (biome precipitation, snow/mist fields, lightning schedule, wind-sway wiring), `ui_flow_verify.gd` (menu flows, cancel order, focus), `ui_scale_verify.gd` (window content scale, text multiplier, live theme refresh), `input_rebind_verify.gd` (action coverage, apply/reset, conflicts, persistence, capture flow), `stream_full_verify.gd` (no LOD inside render distance, lazy collision), `photo_mode_verify.gd` (HUD toggle state, free-camera handoff/look/movement, player artifact and UI-layer hiding, input bindings, screenshot naming), `frame_pacing_verify.gd` (pacing defaults, vsync/FPS application, dynamic-resolution policy).
`worldgen_verify.gd` (parity, seams, continuity, roughness), `worldgen_biome_verify.gd` (region size, vegetation density), `worldgen_river_verify.gd` (river width, banks, floodplain, bed), `worldgen_island_verify.gd` (detached large/small island landmasses), `worldgen_lod_verify.gd` (LOD parity, canopies, patches, shading), `worldgen_tree_verify.gd`, `worldgen_cactus_verify.gd`, `audio_verify.gd` (bank/buses/mappings), `weather_verify.gd` (biome precipitation, snow/mist fields, lightning schedule, wind-sway wiring), `ui_flow_verify.gd` (menu flows, cancel order, focus), `ui_scale_verify.gd` (window content scale, text multiplier, live theme refresh), `input_rebind_verify.gd` (action coverage, apply/reset, conflicts, persistence, capture flow), `stream_full_verify.gd` (no LOD inside render distance, lazy collision), `photo_mode_verify.gd` (HUD toggle state, free-camera handoff/look/movement, player artifact and UI-layer hiding, input bindings, screenshot naming), `frame_pacing_verify.gd` (pacing defaults, vsync/FPS application, dynamic-resolution policy), `shadow_proxy_verify.gd` (leaf wind marker, shader `IN_SHADOW_PASS` wiring).
- `redot --headless --path . res://tools/player_target_verify.tscn` is scene-based because autoload identifiers are unavailable to `--script` harnesses; use a `.tscn` whenever a test references `GameConfig`/`AudioManager`.
- `redot --headless --path . res://tools/explosives_verify.tscn` checks flint-and-steel activation, TNT/nuke radii, spherical edit persistence, unbreakable blocks, and item-only placement rejection.
- `redot --path . res://tools/shadow_proxy_measure.tscn` needs a rendering display: it loads the real scene with a flat canopy, sweeps the sun, and prints the shadow-edge temporal MAD for the leaf shadow proxy off/off-control/on with and without TAA.
- `tools/icon_check.gd` needs a rendering display and fails headless.
- Benchmarks: `worldgen_benchmark.gd`, `worldgen_mesh_benchmark.gd`, and `worldgen_stream_benchmark.gd` (the last is minutes long at 32 chunks).

Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Property names and file references are included so each item is easy to find.
- [x] Narrow PCF on hard shadows — Medium directional filter at half blur width (`RenderingServer.directional_soft_shadow_filter_set_quality`) when `soft_shadows` is off; the soft-shadow toggle keeps its contact-hardening path
- [x] F9 shadow capture tool — `game/shadow_capture.gd` + `Main._get_shadow_capture_state()` save 30 lossless frames and per-frame camera/sun/time/pause/edits state to `user://shadow_captures/`, so reported artifacts can be replayed deterministically
- [x] Hard-shadow acne and cascade boundaries — raised Sun `shadow_bias` 0.05 -> 0.15 and `shadow_normal_bias` 2.0 -> 3.5, and enabled `directional_shadow_blend_splits` so cascade edges stop drawing moving diagonal lines across flat ground. Visual confirmation pending; the residual leaf-shadow item below stays open
- [ ] Residual angle-dependent edge aliasing on direct sun shadows — `alpha_hash` for leaves was implemented and measured in a controlled flat-world canopy A/B (fixed camera and sun path, 12 frames per phase, shadow-region temporal MAD): 0.326 -> 0.381 without TAA, 0.381 -> 0.397 with TAA, and 0.266 -> 0.363 at a 35 m view. It only adds per-frame dither noise: base-mip leaf alpha is binary, so hashing acts only on mip-filtered texels and never stabilizes the silhouette. Reverted; a shadow-only proxy for solid canopy shadows (solid, changes the dappled leaf-shadow look) remains the untried option
- [x] Residual angle-dependent edge aliasing on direct sun shadows — fixed with a shadow-only leaf proxy in `world/block.gdshader`: the shader tests the built-in `IN_SHADOW_PASS` and, for leaves (the only blocks the mesher gives a full `COLOR.a` wind weight), writes opaque coverage (`ALPHA = max(tex.a, solid_leaf_shadows)`); the visible pass keeps the cutout silhouette. The shadow map therefore rasterizes a solid geometric silhouette instead of sampling mip-filtered binary leaf alpha, which is what made the canopy edge crawl. `alpha_hash` was previously tried and measured useless here (0.326 -> 0.381 without TAA, 0.381 -> 0.397 with TAA, 0.266 -> 0.363 at 35 m) because base-mip alpha is binary: it only added per-frame dither on mip-filtered texels. The solid proxy measured on a flat-world canopy A/B (fixed camera, day/night clock frozen, 12 frames per phase at exact 0.1° sun steps via `DayNightCycle.set_time()`, close-up shadow-edge temporal MAD, off -> off control -> on, four runs): without TAA the control tracks off every time (0.0060-0.0074) and the proxy lands at 0.0037-0.0042, a ~45% reduction; with TAA the sampler already smooths the edge, so the reduction is small and the control is noisier (0.0037-0.0040 off -> 0.0015-0.0033 on). Close-up captures show the ragged speckled edge replaced by a clean straight one. Tradeoff: canopy shadows are solid rather than dappled, so it is a player choice — Advanced Graphics -> Shadows "Solid Leaf Shadows" (default on, all presets carry the key; off restores the dappled cutout shadows). `tools/shadow_proxy_verify.gd` pins the wiring and preset/UI coverage, and `tools/shadow_proxy_measure.gd` reproduces the A/B
- [x] Cascade split ratios — `Main._apply_graphics()` reserves the first split for the 6 m near range and scales the remaining splits from it; together with the 16384 atlas this resolved the remaining distant pulsation. 32-bit shadow depth and extra PCF samples measured no benefit
- [x] Isolate volumetric fog and SSIL/SSAO in a controlled A/B — flat-world canopy scene, fixed camera and sun path, 12 frames per phase; shadow-region temporal MAD with both off 0.381, fog off (SSAO/SSIL on) 0.381, SSAO/SSIL off (fog on) 0.369, both on 0.369. With TAA the pair moved 0.397 -> 0.387. Neither effect localizes shimmer: toggling SSAO/SSIL changes nothing and fog improves the number slightly through its temporal reprojection, so the residual edge instability is the shadow-map sampler, not these screen-space effects

Expand Down
3 changes: 3 additions & 0 deletions autoload/game_config.gd
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ const GRAPHICS_PRESETS := {
"shadow_opacity": 0.9,
"shadow_blur": 1.0,
"soft_shadows": false,
"solid_leaf_shadows": true,
"taa": false,
"fsr_scale": 0.66,
"msaa": 0,
Expand All @@ -132,6 +133,7 @@ const GRAPHICS_PRESETS := {
"shadow_opacity": 0.9,
"shadow_blur": 1.0,
"soft_shadows": true,
"solid_leaf_shadows": true,
"taa": true,
"fsr_scale": 0.77,
"msaa": 1,
Expand All @@ -154,6 +156,7 @@ const GRAPHICS_PRESETS := {
"shadow_opacity": 0.95,
"shadow_blur": 1.2,
"soft_shadows": true,
"solid_leaf_shadows": true,
"taa": true,
"fsr_scale": 0.9,
"msaa": 1,
Expand Down
6 changes: 6 additions & 0 deletions game/main.gd
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,12 @@ func _apply_graphics() -> void:
RenderingServer.directional_soft_shadow_filter_set_quality(directional_quality)
RenderingServer.positional_soft_shadow_filter_set_quality(shadow_quality)
_sun.light_angular_distance = 0.3 if soft_shadows else 0.0
# Leaf shadow proxy: solid canopy shadows are aliasing-free but lose the
# dappled leaf look, so it is a player choice.
var registry := world.get_registry()
if registry != null and registry.material is ShaderMaterial:
(registry.material as ShaderMaterial).set_shader_parameter(
"solid_leaf_shadows", 1.0 if bool(graphics["solid_leaf_shadows"]) else 0.0)
var viewport := get_viewport()
if viewport:
var fsr_scale := float(graphics["fsr_scale"])
Expand Down
208 changes: 208 additions & 0 deletions tools/shadow_proxy_measure.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
## Controlled A/B measurement for the leaf shadow proxy. Loads the real gameplay
## scene with a flat world, builds a leaf canopy over flat ground, freezes the
## day/night clock, parks the camera, and measures shadow-edge temporal MAD
## across exact sun steps — off, off again as a control, then on, for each TAA
## state, all in one run so nothing else varies. Writes frames to
## user://shadow_proxy_measure/ and prints the numbers. Requires a rendering
## display (not headless):
## redot --path . res://tools/shadow_proxy_measure.tscn
##
## The sun must be driven through DayNightCycle.set_time(): its _process calls
## _apply() every frame, which rewrites the sun rotation from time_hours, so a
## direct _sun.rotation_degrees write is overwritten before the frame renders.
extends Node

const FRAMES_PER_PHASE := 12
const OUTPUT_DIR := "user://shadow_proxy_measure"
const SUNRISE_HOUR := 6.0
const SUN_ELEVATION_START := 30.0
const SUN_ELEVATION_STEP := 0.1
const CANOPY_RADIUS := 8
const GROUND_RADIUS := 26
const CANOPY_HEIGHT_OFFSET := 4

var _main: Node3D
var _world: VoxelWorld
var _sun: DirectionalLight3D
var _day_night: DayNightCycle
var _camera: Camera3D
var _material: ShaderMaterial
var _base := Vector3.ZERO


func _ready() -> void:
_main = load("res://game/main.tscn").instantiate()
# Flat world keeps the canopy shadow on a stable, unbroken surface.
GameConfig.world["world_type"] = 1
GameConfig.world["seed"] = 918273
GameConfig.world["tree_density"] = 0.0
GameConfig.set_setting("render_distance", 2)
add_child(_main)
_run.call_deferred()


func _run() -> void:
await get_tree().process_frame
await get_tree().process_frame
_world = _main.get_node("World") as VoxelWorld
_sun = _main.get_node("Sun") as DirectionalLight3D
_day_night = _main.get_node("DayNight") as DayNightCycle
# DayNightCycle._apply() rewrites the sun rotation from time_hours every
# frame, so the sweep must go through set_time(); writing _sun.rotation
# directly would be overwritten before the frame renders. Freeze the clock
# so time_hours only changes on our steps.
_day_night.auto_advance = false
var player = _main.get_node("Player")
_camera = player.camera as Camera3D
_material = _world.get_registry().material as ShaderMaterial
# Let the flat spawn chunks stream in, then flatten the area and lay a canopy.
await _wait_seconds(8.0)
_base = Vector3(roundi(player.global_position.x) + 0.5, float(VoxelDefs.SEA_LEVEL + 1), roundi(player.global_position.z) + 0.5)
_sculpt_canopy(Vector3i(_base))
# The sculpted chunks remesh over several frames; wait for the commit queue
# to drain so measured frames are not competing with chunk rebuilds.
await _wait_seconds(12.0)
# Park the camera in a fixed oblique pose framing the shadow footprint. The
# camera is held at a fixed world pose for both phases, so the only variable
# is the proxy; the sun sweep moves the shadow through the framed ground.
player.set_physics_process(false)
player.set_process_input(false)
_camera.top_level = true
_sun.directional_shadow_max_distance = 120.0
_sun.shadow_blur = 0.5
_sun.light_angular_distance = 0.0
RenderingServer.directional_soft_shadow_filter_set_quality(RenderingServer.SHADOW_QUALITY_SOFT_MEDIUM)
# Screen-space and volumetric effects add their own temporal noise; the
# reported A/B isolates the shadow-map sampler.
var environment: Environment = _main.get_node("WorldEnvironment").environment
environment.ssao_enabled = false
environment.ssil_enabled = false
environment.sdfgi_enabled = false
environment.volumetric_fog_enabled = false
environment.ssr_enabled = false
environment.glow_enabled = false
var mid_elevation := SUN_ELEVATION_START + (FRAMES_PER_PHASE - 1) * SUN_ELEVATION_STEP * 0.5
_set_sun_elevation(mid_elevation)
await _wait_seconds(0.5)
var shadow_center := _shadow_center()
# Tight, low view across the shadow boundary so the edge fills the frame.
var edge := shadow_center + Vector3(0.0, 0.0, float(CANOPY_RADIUS))
_camera.global_position = edge + Vector3(0.0, 1.6, 7.0)
_camera.look_at(edge + Vector3(0.0, 0.2, -2.0), Vector3.UP)
_camera.fov = 40.0
await _wait_seconds(1.0)
var viewport := get_viewport()
var results := {}
for taa in [false, true]:
viewport.use_taa = taa
viewport.scaling_3d_mode = Viewport.SCALING_3D_MODE_BILINEAR
viewport.scaling_3d_scale = 1.0
await _wait_seconds(0.5)
# off, off again (control for run-to-run noise), then on.
results[taa] = [await _measure(false, taa), await _measure(false, taa), await _measure(true, taa)]
print("SHADOW PROXY MEASURE (off -> off(control) -> on):")
print(" no TAA: %.4f -> %.4f -> %.4f" % results[false])
print(" TAA: %.4f -> %.4f -> %.4f" % results[true])
get_tree().quit(0)


## DayNightCycle maps elevation to the hour it renders from
## (`elevation = (time_hours - SUNRISE_HOUR) / 24 * 360`); invert that so the
## sweep is exact and the energy/color grading follows the same pose.
func _set_sun_elevation(elevation: float) -> void:
_day_night.set_time(SUNRISE_HOUR + elevation * 24.0 / 360.0)


## Ground point hit by the shadow of the canopy slab center, from the canopy
## height above the ground and the sun's light direction.
func _shadow_center() -> Vector3:
var light_dir := -_sun.global_transform.basis.z
var height := float(CANOPY_HEIGHT_OFFSET)
var horizontal := Vector3(light_dir.x, 0.0, light_dir.z)
var vertical := maxf(-light_dir.y, 0.05)
return _base + horizontal * (height / vertical)


## Builds an explicit flat platform plus a solid leaf slab above it, so the
## ground under the canopy gets a clean island of leaf shadow regardless of the
## generated terrain around it.
func _sculpt_canopy(base: Vector3i) -> void:
for dz in range(-GROUND_RADIUS, GROUND_RADIUS + 1):
for dx in range(-GROUND_RADIUS, GROUND_RADIUS + 1):
for dy in range(-4, 0):
_world.place_block(base + Vector3i(dx, dy, dz), BlockRegistry.BLOCK_STONE)
_world.place_block(base + Vector3i(dx, 0, dz), BlockRegistry.BLOCK_GRASS)
for dz in range(-CANOPY_RADIUS, CANOPY_RADIUS + 1):
for dx in range(-CANOPY_RADIUS, CANOPY_RADIUS + 1):
_world.place_block(base + Vector3i(dx, CANOPY_HEIGHT_OFFSET, dz), BlockRegistry.BLOCK_LEAVES)


func _measure(proxy_on: bool, taa: bool) -> float:
if _material:
_material.set_shader_parameter("solid_leaf_shadows", 1.0 if proxy_on else 0.0)
DirAccess.make_dir_recursive_absolute(OUTPUT_DIR)
# TAA blends across frames, so let its history flush after the parameter
# change before sampling or the previous phase bleeds into this one.
await _wait_seconds(0.5)
var phase := "on" if proxy_on else "off"
var taa_tag := "taa" if taa else "notaa"
var images: Array[Image] = []
for index in FRAMES_PER_PHASE:
_set_sun_elevation(SUN_ELEVATION_START + index * SUN_ELEVATION_STEP)
await RenderingServer.frame_post_draw
await RenderingServer.frame_post_draw
var image := get_viewport().get_texture().get_image()
image.save_png("%s/%s_%s_%02d.png" % [OUTPUT_DIR, taa_tag, phase, index])
images.append(image)
var total := 0.0
var pairs := 0
for i in range(images.size() - 1):
total += _frame_mad(images[i], images[i + 1])
pairs += 1
return total / maxf(float(pairs), 1.0)


## Mean absolute luminance difference between consecutive frames over the
## shadow boundary. Shadow interiors and fully lit ground are temporally stable,
## so only pixels whose luminance sits between the lit plateau and the shadow
## floor — the shadow edge band — are sampled, which is where the leaf cutout
## silhouette aliases.
func _frame_mad(a: Image, b: Image) -> float:
var width := mini(a.get_width(), b.get_width())
var height := mini(a.get_height(), b.get_height())
var step := 2
var luminances := PackedFloat32Array()
var coords := PackedInt32Array()
var py := 0
while py < height:
var px := 0
while px < width:
var color := a.get_pixel(px, py)
luminances.append(0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b)
coords.append(py * width + px)
px += step
py += step
if luminances.is_empty():
return 0.0
var sorted := Array(luminances)
sorted.sort()
var low: float = sorted[int(sorted.size() * 0.1)]
var high: float = sorted[int(sorted.size() * 0.9)]
var band_low := low + (high - low) * 0.2
var band_high := low + (high - low) * 0.8
var sum := 0.0
var count := 0
for index in luminances.size():
var value := luminances[index]
if value < band_low or value > band_high:
continue
var point := coords[index]
var color_b := b.get_pixel(point % width, point / width)
var lb := 0.2126 * color_b.r + 0.7152 * color_b.g + 0.0722 * color_b.b
sum += absf(value - lb)
count += 1
return sum / maxf(float(count), 1.0)


func _wait_seconds(seconds: float) -> void:
await get_tree().create_timer(seconds).timeout
1 change: 1 addition & 0 deletions tools/shadow_proxy_measure.gd.uid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uid://b8e5jov6x4qqd
Loading
Loading