From d0ad7abfd12166e0250ee8c5b83e39d90a1a3623 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Tue, 15 Sep 2026 07:38:40 +0100 Subject: [PATCH 1/2] Add solid leaf shadows in the shadow pass Leaves sampled mip-filtered binary alpha in the shadow pass, so the canopy shadow silhouette crawled as the sun and view angle changed. The block shader now tests the built-in IN_SHADOW_PASS and writes opaque coverage for leaves (the only blocks the mesher gives a full COLOR.a wind weight), so the shadow map rasterizes a solid geometric silhouette instead of a dithered cutout. The visible pass keeps the cutout look, non-leaf cutout blocks are unaffected, and solid_leaf_shadows = 0 restores the old dappled shadows. Measured on a flat-world canopy A/B (fixed camera, 0.1 sun steps, close-up shadow-edge temporal MAD, off -> off control -> on): no TAA 0.0031 -> 0.0027 -> 0.0018, TAA 0.0017 -> 0.0018 -> 0.0012. --- AGENTS.md | 4 +- ROADMAP.md | 2 +- tools/shadow_proxy_measure.gd | 184 ++++++++++++++++++++++++++++++ tools/shadow_proxy_measure.gd.uid | 1 + tools/shadow_proxy_measure.tscn | 6 + tools/shadow_proxy_verify.gd | 92 +++++++++++++++ tools/shadow_proxy_verify.gd.uid | 1 + world/block.gdshader | 13 ++- 8 files changed, 300 insertions(+), 3 deletions(-) create mode 100644 tools/shadow_proxy_measure.gd create mode 100644 tools/shadow_proxy_measure.gd.uid create mode 100644 tools/shadow_proxy_measure.tscn create mode 100644 tools/shadow_proxy_verify.gd create mode 100644 tools/shadow_proxy_verify.gd.uid diff --git a/AGENTS.md b/AGENTS.md index 6d35f55..057f9f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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, non-leaf cutout blocks are unaffected, and `solid_leaf_shadows = 0` restores the old 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). @@ -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/`): - `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). diff --git a/ROADMAP.md b/ROADMAP.md index 6255302..dcefd65 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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, 12 frames per phase at 0.1° sun steps, close-up shadow-edge temporal MAD, off -> off control -> on): no TAA 0.0031 -> 0.0027 -> 0.0018, TAA 0.0017 -> 0.0018 -> 0.0012 (the control run pins run-to-run noise, and the on result sits clearly below it in both cases), and the close-up captures show the ragged speckled edge replaced by a clean straight one. Tradeoff: canopy shadows are solid rather than dappled. `tools/shadow_proxy_verify.gd` pins the wiring and `tools/shadow_proxy_measure.gd` reproduces the A/B; `solid_leaf_shadows = 0` restores the old look - [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 diff --git a/tools/shadow_proxy_measure.gd b/tools/shadow_proxy_measure.gd new file mode 100644 index 0000000..d5878d8 --- /dev/null +++ b/tools/shadow_proxy_measure.gd @@ -0,0 +1,184 @@ +## 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, parks the +## camera and sun at fixed poses, and measures shadow-region temporal MAD across +## sun steps — once with the proxy off and once on, in the same run so nothing +## else varies. Writes frames to user://shadow_proxy_measure/ and prints the two +## numbers. Requires a rendering display (not headless): +## redot --path . res://tools/shadow_proxy_measure.tscn +extends Node + +const FRAMES_PER_PHASE := 12 +const OUTPUT_DIR := "user://shadow_proxy_measure" +const SUN_ELEVATION_START := 30.0 +const SUN_ELEVATION_STEP := 0.1 +const SUN_YAW := -35.0 +const CANOPY_RADIUS := 8 +const GROUND_RADIUS := 26 +const CANOPY_HEIGHT_OFFSET := 4 + +var _main: Node3D +var _world: VoxelWorld +var _sun: DirectionalLight3D +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", 4) + 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 + 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)) + await _wait_seconds(3.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 + _sun.rotation_degrees = Vector3(-mid_elevation, SUN_YAW, 0.0) + await _wait_seconds(0.5) + var shadow_center := _shadow_center(mid_elevation) + # 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) + + +## 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(elevation: float) -> 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) + 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: + _sun.rotation_degrees = Vector3(-(SUN_ELEVATION_START + index * SUN_ELEVATION_STEP), SUN_YAW, 0.0) + 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 diff --git a/tools/shadow_proxy_measure.gd.uid b/tools/shadow_proxy_measure.gd.uid new file mode 100644 index 0000000..2441b21 --- /dev/null +++ b/tools/shadow_proxy_measure.gd.uid @@ -0,0 +1 @@ +uid://b8e5jov6x4qqd diff --git a/tools/shadow_proxy_measure.tscn b/tools/shadow_proxy_measure.tscn new file mode 100644 index 0000000..818a4be --- /dev/null +++ b/tools/shadow_proxy_measure.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tools/shadow_proxy_measure.gd" id="1_measure"] + +[node name="ShadowProxyMeasure" type="Node"] +script = ExtResource("1_measure") diff --git a/tools/shadow_proxy_verify.gd b/tools/shadow_proxy_verify.gd new file mode 100644 index 0000000..b310e0a --- /dev/null +++ b/tools/shadow_proxy_verify.gd @@ -0,0 +1,92 @@ +## Headless regression check for the leaf shadow proxy wiring. The proxy lives +## entirely in world/block.gdshader: in the shadow pass leaf fragments write +## solid coverage so the shadow map never samples mip-filtered leaf alpha. This +## check pins the shader/source contract the mesher depends on, since a silent +## rename of the wind channel or the shader branch would otherwise break it. +## Run: +## redot --headless --path . --script res://tools/shadow_proxy_verify.gd +extends SceneTree + +const SHADER_PATH := "res://world/block.gdshader" + +var _failures := 0 + + +func _initialize() -> void: + process_frame.connect(_verify, CONNECT_ONE_SHOT) + + +func _verify() -> void: + _check_mesher_leaf_marker() + _check_shader_proxy() + _check_leaf_blocks_independent() + if _failures == 0: + print("SHADOW PROXY VERIFY: PASS") + quit(0) + return + print("SHADOW PROXY VERIFY: FAIL (%d)" % _failures) + quit(1) + + +## The shader keys the proxy off COLOR.a, which ChunkMesher writes as the wind +## weight. Leaves must be the only blocks with a full weight, or the proxy would +## solidify glass/foliage shadows too. +func _check_mesher_leaf_marker() -> void: + var blocks := BlockRegistry.new() + var mesher := ChunkMesher.new(blocks) + var leaf_weights: Array[float] = [] + for id in 256: + if blocks.has_flag(id, BlockRegistry.FLAG_LEAVES): + leaf_weights.append(mesher._wind[id]) + var max_non_leaf := 0.0 + for id in 256: + if blocks.has_flag(id, BlockRegistry.FLAG_LEAVES): + continue + max_non_leaf = maxf(max_non_leaf, mesher._wind[id]) + _expect(not leaf_weights.is_empty(), "block table has no leaf blocks to proxy") + for weight in leaf_weights: + _expect(is_equal_approx(weight, 1.0), "leaf wind weight is %.2f, not 1.0" % weight) + _expect(max_non_leaf < 0.99, "non-leaf wind weight %.2f collides with the leaf marker" % max_non_leaf) + mesher = null + blocks = null + + +func _check_shader_proxy() -> void: + var file := FileAccess.open(SHADER_PATH, FileAccess.READ) + _expect(file != null, "cannot read %s" % SHADER_PATH) + if file == null: + return + var source := file.get_as_text() + file.close() + _expect(source.contains("IN_SHADOW_PASS"), "shader does not test IN_SHADOW_PASS") + _expect(source.contains("solid_leaf_shadows"), "shader is missing the solid_leaf_shadows uniform") + _expect(source.contains("step(0.99, COLOR.a)"), "shader does not read the leaf wind marker") + # The proxy must only apply in the shadow pass, never in the visible pass. + var proxy_line := source.find("shadow_proxy") + _expect(proxy_line >= 0, "shader has no shadow_proxy term") + var fragment_start := source.find("void fragment()") + _expect(fragment_start >= 0 and proxy_line > fragment_start, "shadow proxy must be in fragment()") + _expect(source.contains("ALPHA = max(tex.a, shadow_proxy)"), "shader does not apply the proxy to ALPHA") + + +## The proxy is keyed off FLAG_LEAVES, so the flag must stay limited to the +## leaf family and every leaf block must still carry it. +func _check_leaf_blocks_independent() -> void: + var blocks := BlockRegistry.new() + var expected := [ + BlockRegistry.BLOCK_LEAVES, BlockRegistry.BLOCK_SPRUCE_LEAVES, + BlockRegistry.BLOCK_BIRCH_LEAVES, BlockRegistry.BLOCK_ACACIA_LEAVES, + BlockRegistry.BLOCK_JUNGLE_LEAVES, BlockRegistry.BLOCK_MANGROVE_LEAVES, + ] + for id in expected: + _expect(blocks.has_flag(id, BlockRegistry.FLAG_LEAVES), "block %d lost FLAG_LEAVES" % id) + _expect(not blocks.has_flag(BlockRegistry.BLOCK_GLASS, BlockRegistry.FLAG_LEAVES), "glass must not be a leaf") + _expect(not blocks.has_flag(BlockRegistry.BLOCK_TALL_GRASS, BlockRegistry.FLAG_LEAVES), "grass must not be a leaf") + blocks = null + + +func _expect(condition: bool, message: String) -> void: + if condition: + return + _failures += 1 + push_error("shadow_proxy_verify: " + message) diff --git a/tools/shadow_proxy_verify.gd.uid b/tools/shadow_proxy_verify.gd.uid new file mode 100644 index 0000000..a9b4df7 --- /dev/null +++ b/tools/shadow_proxy_verify.gd.uid @@ -0,0 +1 @@ +uid://271dcwx3tjes diff --git a/world/block.gdshader b/world/block.gdshader index 6b34322..229e67c 100644 --- a/world/block.gdshader +++ b/world/block.gdshader @@ -4,6 +4,13 @@ render_mode diffuse_burley, specular_schlick_ggx, alpha_to_coverage; uniform sampler2DArray albedo_texture : source_color, filter_linear_mipmap_anisotropic, repeat_disable; uniform float block_emission : hint_range(0.0, 2.0) = 0.45; uniform float min_light : hint_range(0.0, 1.0) = 0.05; +// Leaf shadow proxy. Leaves keep their cutout silhouette in the visible pass +// but write opaque coverage in the shadow pass, so the shadow map never samples +// mip-filtered leaf alpha and the canopy shadow silhouette stops crawling with +// view/sun angle. This trades the dappled leaf-shadow look for a solid canopy +// shadow. Non-leaf cutout blocks (glass, cross foliage) are unaffected. Set to +// 0 to restore the plain cutout (dappled) leaf shadows. +uniform float solid_leaf_shadows : hint_range(0.0, 1.0) = 1.0; // Camera-submerged strength (0 above water), driven by DayNightCycle. Caustics // are projected from the water surface onto sky-lit upward faces. global uniform float underwater_caustics; @@ -49,7 +56,11 @@ void fragment() { ALBEDO += tex.rgb * vec3(0.24, 0.34, 0.38) * caustic * underwater_caustics * light_data.a * up_face; } - ALPHA = tex.a; + // Leaves are the only blocks with a full wind weight (see ChunkMesher + // _build_light_tables), so COLOR.a doubles as the leaf marker here. + float leaf = step(0.99, COLOR.a); + float shadow_proxy = leaf * solid_leaf_shadows * (IN_SHADOW_PASS ? 1.0 : 0.0); + ALPHA = max(tex.a, shadow_proxy); ALPHA_SCISSOR_THRESHOLD = 0.5; ROUGHNESS = 1.0; METALLIC = 0.0; From 4b8037ebbe2b93359342da114640ff7d0fd36fc9 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Tue, 15 Sep 2026 08:41:01 +0100 Subject: [PATCH 2/2] Address shadow proxy review feedback - Drive the measurement sun through DayNightCycle.set_time() and freeze auto_advance; its _process rewrites the sun rotation from time_hours every frame, so the manual rotation writes never rendered and the "0.1 deg sun steps" were really the game clock (~20x slower). Re-ran the A/B: no-TAA control tracks off (0.0060-0.0074) with the proxy at 0.0037-0.0042 (~45% lower); TAA already smooths the edge so it gains little. Also settle the sculpted chunks and flush TAA history before sampling, which removes the run-to-run variance. - Expose the tradeoff as Advanced Graphics -> Shadows "Solid Leaf Shadows" (default on, present in all presets, applied in Main._apply_graphics), so players can restore dappled shadows. - Derive the verifier's expected leaf set from block names so a future leaf block cannot silently miss FLAG_LEAVES, and pin the preset/UI coverage of the new graphics key. --- AGENTS.md | 2 +- ROADMAP.md | 2 +- autoload/game_config.gd | 3 +++ game/main.gd | 6 +++++ tools/shadow_proxy_measure.gd | 48 ++++++++++++++++++++++++++--------- tools/shadow_proxy_verify.gd | 46 +++++++++++++++++++++++++++------ ui/graphics_sections.gd | 1 + world/block.gdshader | 4 +-- 8 files changed, 88 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 057f9f0..5108c9e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +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, non-leaf cutout blocks are unaffected, and `solid_leaf_shadows = 0` restores the old dappled cutout shadows. `tools/shadow_proxy_verify.gd` (headless) and `tools/shadow_proxy_measure.gd` (display) cover it. +- 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). diff --git a/ROADMAP.md b/ROADMAP.md index dcefd65..4c2ea6e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 -- [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, 12 frames per phase at 0.1° sun steps, close-up shadow-edge temporal MAD, off -> off control -> on): no TAA 0.0031 -> 0.0027 -> 0.0018, TAA 0.0017 -> 0.0018 -> 0.0012 (the control run pins run-to-run noise, and the on result sits clearly below it in both cases), and the close-up captures show the ragged speckled edge replaced by a clean straight one. Tradeoff: canopy shadows are solid rather than dappled. `tools/shadow_proxy_verify.gd` pins the wiring and `tools/shadow_proxy_measure.gd` reproduces the A/B; `solid_leaf_shadows = 0` restores the old look +- [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 diff --git a/autoload/game_config.gd b/autoload/game_config.gd index 5de5de7..6e59d0d 100644 --- a/autoload/game_config.gd +++ b/autoload/game_config.gd @@ -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, @@ -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, @@ -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, diff --git a/game/main.gd b/game/main.gd index d87dbaf..43217cf 100644 --- a/game/main.gd +++ b/game/main.gd @@ -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"]) diff --git a/tools/shadow_proxy_measure.gd b/tools/shadow_proxy_measure.gd index d5878d8..0764f24 100644 --- a/tools/shadow_proxy_measure.gd +++ b/tools/shadow_proxy_measure.gd @@ -1,17 +1,22 @@ ## 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, parks the -## camera and sun at fixed poses, and measures shadow-region temporal MAD across -## sun steps — once with the proxy off and once on, in the same run so nothing -## else varies. Writes frames to user://shadow_proxy_measure/ and prints the two -## numbers. Requires a rendering display (not headless): +## 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 SUN_YAW := -35.0 const CANOPY_RADIUS := 8 const GROUND_RADIUS := 26 const CANOPY_HEIGHT_OFFSET := 4 @@ -19,6 +24,7 @@ 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 @@ -30,7 +36,7 @@ func _ready() -> void: GameConfig.world["world_type"] = 1 GameConfig.world["seed"] = 918273 GameConfig.world["tree_density"] = 0.0 - GameConfig.set_setting("render_distance", 4) + GameConfig.set_setting("render_distance", 2) add_child(_main) _run.call_deferred() @@ -40,6 +46,12 @@ func _run() -> void: 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 @@ -47,7 +59,9 @@ func _run() -> void: 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)) - await _wait_seconds(3.0) + # 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. @@ -68,9 +82,9 @@ func _run() -> void: environment.ssr_enabled = false environment.glow_enabled = false var mid_elevation := SUN_ELEVATION_START + (FRAMES_PER_PHASE - 1) * SUN_ELEVATION_STEP * 0.5 - _sun.rotation_degrees = Vector3(-mid_elevation, SUN_YAW, 0.0) + _set_sun_elevation(mid_elevation) await _wait_seconds(0.5) - var shadow_center := _shadow_center(mid_elevation) + 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) @@ -92,9 +106,16 @@ func _run() -> void: 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(elevation: float) -> Vector3: +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) @@ -120,11 +141,14 @@ 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: - _sun.rotation_degrees = Vector3(-(SUN_ELEVATION_START + index * SUN_ELEVATION_STEP), SUN_YAW, 0.0) + _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() diff --git a/tools/shadow_proxy_verify.gd b/tools/shadow_proxy_verify.gd index b310e0a..372d7d9 100644 --- a/tools/shadow_proxy_verify.gd +++ b/tools/shadow_proxy_verify.gd @@ -20,6 +20,7 @@ func _verify() -> void: _check_mesher_leaf_marker() _check_shader_proxy() _check_leaf_blocks_independent() + _check_graphics_toggle() if _failures == 0: print("SHADOW PROXY VERIFY: PASS") quit(0) @@ -28,6 +29,29 @@ func _verify() -> void: quit(1) +## The proxy is player-facing, so the graphics key must exist in every preset +## (AGENTS.md: a new key goes into all GRAPHICS_PRESETS entries) and have a UI +## row, or the setting silently does nothing. +func _check_graphics_toggle() -> void: + # Autoloads and UI class_names are not available at --script parse time, so + # fetch them at runtime. + var config: Node = root.get_node_or_null("GameConfig") + if config == null: + _expect(false, "GameConfig autoload is missing") + return + for preset in config.GRAPHICS_PRESETS: + _expect((config.GRAPHICS_PRESETS[preset] as Dictionary).has("solid_leaf_shadows"), + "graphics preset %d is missing solid_leaf_shadows" % preset) + var sections_script: GDScript = load("res://ui/graphics_sections.gd") + var found := false + if sections_script != null: + for section in sections_script.SECTIONS: + for row in section["rows"]: + if row.get("key", "") == "solid_leaf_shadows": + found = true + _expect(found, "no graphics section row exposes solid_leaf_shadows") + + ## The shader keys the proxy off COLOR.a, which ChunkMesher writes as the wind ## weight. Leaves must be the only blocks with a full weight, or the proxy would ## solidify glass/foliage shadows too. @@ -70,16 +94,22 @@ func _check_shader_proxy() -> void: ## The proxy is keyed off FLAG_LEAVES, so the flag must stay limited to the -## leaf family and every leaf block must still carry it. +## leaf family and every leaf block must still carry it. The expected set is +## derived from the block table names so a newly added leaf cannot silently +## miss the flag (and therefore the proxy). func _check_leaf_blocks_independent() -> void: var blocks := BlockRegistry.new() - var expected := [ - BlockRegistry.BLOCK_LEAVES, BlockRegistry.BLOCK_SPRUCE_LEAVES, - BlockRegistry.BLOCK_BIRCH_LEAVES, BlockRegistry.BLOCK_ACACIA_LEAVES, - BlockRegistry.BLOCK_JUNGLE_LEAVES, BlockRegistry.BLOCK_MANGROVE_LEAVES, - ] - for id in expected: - _expect(blocks.has_flag(id, BlockRegistry.FLAG_LEAVES), "block %d lost FLAG_LEAVES" % id) + var named_leaves := 0 + for id in 256: + if not blocks.is_valid_id(id): + continue + var block_name := blocks.get_block_name(id) + if not block_name.to_lower().contains("leaves"): + continue + named_leaves += 1 + _expect(blocks.has_flag(id, BlockRegistry.FLAG_LEAVES), + "block %d ('%s') looks like a leaf but lacks FLAG_LEAVES" % [id, block_name]) + _expect(named_leaves >= 6, "expected at least six leaf blocks, found %d" % named_leaves) _expect(not blocks.has_flag(BlockRegistry.BLOCK_GLASS, BlockRegistry.FLAG_LEAVES), "glass must not be a leaf") _expect(not blocks.has_flag(BlockRegistry.BLOCK_TALL_GRASS, BlockRegistry.FLAG_LEAVES), "grass must not be a leaf") blocks = null diff --git a/ui/graphics_sections.gd b/ui/graphics_sections.gd index 2a3bd13..65ef85b 100644 --- a/ui/graphics_sections.gd +++ b/ui/graphics_sections.gd @@ -12,6 +12,7 @@ const SECTIONS := [ ]}, {"title": "SHADOWS", "rows": [ {"type": "check", "key": "soft_shadows", "label": "Soft Shadows", "tooltip": "Shadow edges vibrate without TAA or FSR2."}, + {"type": "check", "key": "solid_leaf_shadows", "label": "Solid Leaf Shadows", "tooltip": "Leaves cast a solid canopy shadow instead of a dappled one, which stops the leaf-shadow edge from crawling."}, {"type": "slider", "key": "shadow_max_distance", "label": "Shadow Distance", "min": 64.0, "max": 512.0, "step": 8.0, "format": "%d m"}, {"type": "slider", "key": "shadow_opacity", "label": "Shadow Opacity", "min": 0.0, "max": 1.0, "step": 0.05, "format": "%.2f"}, {"type": "slider", "key": "shadow_blur", "label": "Shadow Blur", "min": 0.0, "max": 4.0, "step": 0.1, "format": "%.1f"}, diff --git a/world/block.gdshader b/world/block.gdshader index 229e67c..18995ea 100644 --- a/world/block.gdshader +++ b/world/block.gdshader @@ -8,8 +8,8 @@ uniform float min_light : hint_range(0.0, 1.0) = 0.05; // but write opaque coverage in the shadow pass, so the shadow map never samples // mip-filtered leaf alpha and the canopy shadow silhouette stops crawling with // view/sun angle. This trades the dappled leaf-shadow look for a solid canopy -// shadow. Non-leaf cutout blocks (glass, cross foliage) are unaffected. Set to -// 0 to restore the plain cutout (dappled) leaf shadows. +// shadow. Non-leaf cutout blocks (glass, cross foliage) are unaffected. Driven +// by the Advanced Graphics -> Shadows "Solid Leaf Shadows" setting. uniform float solid_leaf_shadows : hint_range(0.0, 1.0) = 1.0; // Camera-submerged strength (0 above water), driven by DayNightCycle. Caustics // are projected from the water surface onto sky-lit upward faces.